瀏覽代碼

infer extended-protocol parameter types for Describe and Bind

ParameterDescription echoed only the client's Parse OIDs, so a statement
prepared with no type declarations described zero parameters and pgx's
default modes failed client-side with "expected 0 arguments, got 1".

The negotiated list now always has one entry per placeholder: a client's
non-zero OID wins, an OID of 0 is inferred from the SQL context, and a
parameter with no usable context reports TEXT (25) instead of 0. Bind
decodes with the negotiated list and Describe (Statement and Portal)
reports it; RowDescription behavior is unchanged.

Inference rules: explicit casts, comparison/containment operands against
a catalog column, INSERT VALUES / UPDATE SET assignment targets
(positional INSERT skips generated columns), BETWEEN, IN, IS DISTINCT
FROM, LIKE patterns, array subscripts (INTEGER), and ANY/ALL operands
(the array type of the column's element type). Unresolved columns and
unparsable statements fall back to TEXT. The catalog is read through the
connection session, so DDL staged by the open transaction is visible.

pgx always binary-encodes arrays for a described array OID, so Bind gains
PostgreSQL binary array decoding for the supported element OIDs and
scalar float4/float8/numeric. Binary numeric elements decode exactly to
decimal text; malformed envelopes report 22P02, unsupported binary
element OIDs keep 0A000, and text-format behavior is unchanged.
Danilo Fragoso 1 天之前
父節點
當前提交
198ef982dc

+ 1 - 1
README.md

@@ -321,7 +321,7 @@ pgcli postgresql://localhost:5432/pizzasql
    - Use `INTEGER PRIMARY KEY AUTOINCREMENT`, not `SERIAL`
    - Use `TEXT`, not `VARCHAR` with enforced length
 2. **Parameter placeholders**: use `$1, $2...` (PostgreSQL style) or `?` (SQLite style). PG Bind rewrites `$n` only outside quoted regions and line/block comments. The HTTP `/query` API substitutes `?` textually, so a statement that uses the jsonb `?` key-existence operator cannot also use `?` positional parameters; use the PostgreSQL protocol or `$n`-free SQL there. Initialization probes such as `SELECT version()` and `SHOW server_version` match complete token sequences, never substrings inside SQL literals or unrelated projections.
-3. **Extended protocol scope**: Parse accepts one command; Bind requests must use text parameters/all-text results. `Describe` returns `ParameterDescription` then `NoData` without executing anything; `Execute` still sends the real `RowDescription`. Binary results and nonzero Execute row limits fail with SQLSTATE `0A000` before side effects. Portals are one-shot, not resumable. Unsupported binary parameter OIDs also report `0A000`.
+3. **Extended protocol scope**: Parse accepts one command. Describe never executes SQL: statement Describe returns `ParameterDescription` with the negotiated parameter types (a client Parse OID wins, an OID of 0 is inferred from casts, comparisons, assignment targets, and ANY/ALL operands, and otherwise reports TEXT rather than 0) followed by `NoData`, and portal Describe returns the same `ParameterDescription` then `NoData`; `Execute` still sends the real `RowDescription`. Bind accepts text parameters for every type and binary parameters for booleans, integers, floats, numeric, jsonb, and the supported array OIDs; unsupported binary parameter OIDs report `0A000`. Binary results and nonzero Execute row limits fail with SQLSTATE `0A000` before side effects. Portals are one-shot, not resumable.
 4. **PostgreSQL compatibility limits**: no schemas or roles. Arrays are one-dimensional and JSONB is a native canonical-text type; see `GET /capabilities` for the exact surface.
 
 ---

+ 4 - 0
go.mod

@@ -7,11 +7,15 @@ require (
 	github.com/dustin/go-humanize v1.0.1 // indirect
 	github.com/goccy/go-json v0.10.6 // indirect
 	github.com/google/uuid v1.6.0 // indirect
+	github.com/jackc/pgpassfile v1.0.0 // indirect
+	github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
+	github.com/jackc/pgx/v5 v5.11.0
 	github.com/lib/pq v1.12.3 // indirect
 	github.com/mattn/go-isatty v0.0.20 // indirect
 	github.com/ncruces/go-strftime v1.0.0 // indirect
 	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
 	golang.org/x/sys v0.42.0 // indirect
+	golang.org/x/text v0.29.0 // indirect
 	modernc.org/libc v1.72.0 // indirect
 	modernc.org/mathutil v1.7.1 // indirect
 	modernc.org/memory v1.11.0 // indirect

+ 10 - 0
go.sum

@@ -18,6 +18,12 @@ github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
 github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
+github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
+github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
+github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
 github.com/klauspost/compress v1.15.15 h1:EF27CXIuDsYJ6mmvtBRlEuB2UVOqHG1tAXgZ7yIO+lw=
 github.com/klauspost/compress v1.15.15/go.mod h1:ZcK2JAFqKOpnBlxcLsJzYfrS9X1akm9fHZNnD9+Vo/4=
 github.com/klauspost/cpuid/v2 v2.2.3 h1:sxCkb+qR91z4vsqw4vGGZlDgPz3G7gjaLyK3V8y70BU=
@@ -39,6 +45,8 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
 github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
 github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
 github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
@@ -52,6 +60,8 @@ golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
 golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
+golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

+ 2 - 2
pkg/capabilities/capabilities.go

@@ -160,7 +160,7 @@ func Current() *Document {
 			SQLiteCompat:   "3.35.0",
 			SimpleQuery:    true,
 			ExtendedQuery:  true,
-			Describe:       Feature{Supported: true, Status: "compatibility_no_data", Notes: "statement Describe returns ParameterDescription then NoData; portal Describe returns NoData; Execute still emits the real RowDescription"},
+			Describe:       Feature{Supported: true, Status: "compatibility_no_data", Notes: "statement Describe returns ParameterDescription with the negotiated parameter types (a client Parse OID wins; an OID of 0 is inferred from casts, comparisons, assignment targets, and ANY/ALL operands, and falls back to TEXT rather than 0) then NoData; portal Describe returns the same ParameterDescription then NoData; Execute still emits the real RowDescription"},
 			BinaryResults:  Feature{Supported: false, Status: "rejected", SQLState: "0A000", Notes: "all-text result formats only; per-column format lists are also rejected"},
 			BoundedExecute: Feature{Supported: false, Status: "rejected", SQLState: "0A000", Notes: "Execute maxRows must be zero; portals are one-shot, not resumable"},
 			ProxyErrors:    true,
@@ -225,7 +225,7 @@ func Current() *Document {
 		Parameters: Parameters{
 			Positional:    true,
 			MaxOID:        "int8/text/bool/bytea/float plus OID 3802 jsonb and the array OIDs text[]/varchar[]/int4[]/int8[]/int2[]/bool[]/float4[]/float8[]/numeric[]",
-			Arrays:        Feature{Supported: true, Status: "text_format", SQLState: "0A000", Notes: "a text-format array parameter is bound as a typed array literal, so id = ANY($1) works with OIDs 1009 text[], 1015 varchar[], 1007 int4[], 1016 int8[], 1005 int2[], 1000 bool[], 1021 float4[], 1022 float8[], and 1231 numeric[]; elements are validated and canonicalized; binary-format array parameters are rejected with 0A000 instead of being misread"},
+			Arrays:        Feature{Supported: true, Status: "text_and_binary", SQLState: "0A000", Notes: "a text-format array parameter is bound as a typed array literal and a PostgreSQL binary array parameter is decoded from its envelope, so id = ANY($1) works with OIDs 1009 text[], 1015 varchar[], 1007 int4[], 1016 int8[], 1005 int2[], 1000 bool[], 1021 float4[], 1022 float8[], and 1231 numeric[]; elements are validated and canonicalized in both formats, and binary numeric elements are decoded exactly to decimal text; a malformed binary envelope reports 22P02 and a binary array with an unsupported element OID (for example bytea[] 1014 or date[] 1182) is rejected with 0A000 instead of being misread"},
 			BinaryNumeric: true,
 		},
 

+ 3 - 3
pkg/capabilities/capabilities_test.go

@@ -209,10 +209,10 @@ func TestArraysAreAdvertised(t *testing.T) {
 	if !params.Supported {
 		t.Fatalf("parameters.arrays = %+v, want supported", params)
 	}
-	if params.Status != "text_format" {
-		t.Fatalf("parameters.arrays status = %q, want text_format", params.Status)
+	if params.Status != "text_and_binary" {
+		t.Fatalf("parameters.arrays status = %q, want text_and_binary", params.Status)
 	}
-	for _, want := range []string{"1009", "1007", "1016", "1000", "1022", "1021", "1231", "1015", "0A000"} {
+	for _, want := range []string{"1009", "1007", "1016", "1000", "1022", "1021", "1231", "1015", "0A000", "binary", "22P02"} {
 		if !strings.Contains(params.Notes, want) {
 			t.Errorf("parameters.arrays notes do not mention %q", want)
 		}

+ 29 - 11
pkg/pgserver/array_param_test.go

@@ -227,31 +227,44 @@ func TestArrayResultMetadataOIDs(t *testing.T) {
 	}
 }
 
-func TestArrayParameterBinaryFormatRejected(t *testing.T) {
+// A binary int4[] parameter is decoded from PostgreSQL's array envelope, so
+// drivers that always encode arrays in binary (pgx) can bind ANY($1).
+func TestArrayParameterBinaryFormatAccepted(t *testing.T) {
 	c, conn := arrayConnection(t)
-	if _, err := c.executor.Execute(parseStmt(t, `CREATE TABLE t (id INTEGER PRIMARY KEY)`)); err != nil {
-		t.Fatal(err)
+	for _, sql := range []string{
+		`CREATE TABLE t (id INTEGER PRIMARY KEY)`,
+		`INSERT INTO t VALUES (1), (2), (3)`,
+	} {
+		if _, err := c.executor.Execute(parseStmt(t, sql)); err != nil {
+			t.Fatalf("%s: %v", sql, err)
+		}
 	}
 
 	parse := NewMessageBuilder()
 	parse.WriteString("s1")
-	parse.WriteString(`SELECT id FROM t WHERE id = ANY($1)`)
+	parse.WriteString(`SELECT id FROM t WHERE id = ANY($1) ORDER BY id`)
 	parse.WriteInt16(1)
 	parse.WriteInt32(1007) // int4[]
 
+	value := encodeBinaryArray(23, [][]byte{encodeInt4(1), encodeInt4(3)})
 	bind := NewMessageBuilder()
 	bind.WriteString("p1")
 	bind.WriteString("s1")
 	bind.WriteInt16(1) // one parameter format code
 	bind.WriteInt16(1) // binary
 	bind.WriteInt16(1) // one parameter
-	bind.WriteInt32(8)
-	bind.WriteBytes([]byte{0, 0, 0, 1, 0, 0, 0, 2})
+	bind.WriteInt32(int32(len(value)))
+	bind.WriteBytes(value)
 	bind.WriteInt16(0)
 
+	execute := NewMessageBuilder()
+	execute.WriteString("p1")
+	execute.WriteInt32(0)
+
 	for _, msg := range []*Message{
 		{Type: MsgParse, Data: parse.Bytes()},
 		{Type: MsgBind, Data: bind.Bytes()},
+		{Type: MsgExecute, Data: execute.Bytes()},
 		{Type: MsgSync},
 	} {
 		if err := c.handleMessage(msg); err != nil {
@@ -259,12 +272,17 @@ func TestArrayParameterBinaryFormatRejected(t *testing.T) {
 		}
 	}
 	messages := readAllMessages(t, conn.bytes())
-	errMsg := findError(messages)
-	if errMsg == nil {
-		t.Fatal("binary array parameter was accepted")
+	if errMsg := findError(messages); errMsg != nil {
+		t.Fatalf("binary array parameter failed: %s", errorCode(errMsg))
 	}
-	if code := errorCode(errMsg); code != ErrCodeFeatureNotSupported {
-		t.Fatalf("binary array error code = %q, want %q", code, ErrCodeFeatureNotSupported)
+	rows := [][]string{}
+	for _, message := range messages {
+		if message.Type == MsgDataRow {
+			rows = append(rows, dataRowValues(t, message))
+		}
+	}
+	if len(rows) != 2 || rows[0][0] != "1" || rows[1][0] != "3" {
+		t.Fatalf("binary ANY($1) rows = %v, want [[1] [3]]", rows)
 	}
 }
 

+ 43 - 73
pkg/pgserver/connection.go

@@ -62,13 +62,19 @@ func nextBackendPID() int32 {
 }
 
 type preparedStatement struct {
-	query     string
+	query string
+	// paramOIDs is the negotiated parameter type list: the client's non-zero
+	// Parse OIDs plus inferred types for the zeros. Describe reports it and
+	// Bind decodes with it.
 	paramOIDs []int32
 }
 
 type portal struct {
 	query    string
 	executed bool
+	// paramOIDs copies the bound statement's negotiated types so Describe
+	// (Portal) reports the same ParameterDescription as Describe (Statement).
+	paramOIDs []int32
 }
 
 // NewConnection creates a new connection handler
@@ -532,7 +538,7 @@ func (c *Connection) handleParse(msg *Message) error {
 	if !isSingleStatementSQL(query) {
 		return c.failExtended(ErrCodeSyntaxError, fmt.Errorf("prepared statements must contain one command"))
 	}
-	c.statements[name] = &preparedStatement{query: query, paramOIDs: oids}
+	c.statements[name] = &preparedStatement{query: query, paramOIDs: c.negotiateParameterOIDs(query, oids)}
 	return c.writeMessage(MsgParseComplete, nil)
 }
 
@@ -614,7 +620,7 @@ func (c *Connection) handleBind(msg *Message) error {
 		}
 		return c.failExtended(code, err)
 	}
-	c.portals[portalName] = &portal{query: query}
+	c.portals[portalName] = &portal{query: query, paramOIDs: append([]int32(nil), statement.paramOIDs...)}
 	return c.writeMessage(MsgBindComplete, nil)
 }
 
@@ -635,18 +641,17 @@ func (c *Connection) handleDescribe(msg *Message) error {
 		if !ok {
 			return c.failExtended(ErrCodeInvalidParameter, fmt.Errorf("prepared statement %q does not exist", name))
 		}
-		mb := NewMessageBuilder()
-		mb.WriteInt16(int16(len(statement.paramOIDs)))
-		for _, oid := range statement.paramOIDs {
-			mb.WriteInt32(oid)
-		}
-		if err := c.writeMessage(MsgParameterDescription, mb.Bytes()); err != nil {
+		if err := c.sendParameterDescription(statement.paramOIDs); err != nil {
 			return err
 		}
 	case 'P':
-		if _, ok := c.portals[name]; !ok {
+		portal, ok := c.portals[name]
+		if !ok {
 			return c.failExtended(ErrCodeInvalidParameter, fmt.Errorf("portal %q does not exist", name))
 		}
+		if err := c.sendParameterDescription(portal.paramOIDs); err != nil {
+			return err
+		}
 	default:
 		return c.failExtended(ErrCodeProtocolViolation, fmt.Errorf("invalid Describe target"))
 	}
@@ -1110,70 +1115,20 @@ func parameterFormat(formats []int16, index int) int16 {
 
 func bindQuery(query string, params []boundParameter) (string, error) {
 	var result strings.Builder
-	var quote byte
-	for i := 0; i < len(query); {
-		ch := query[i]
-		if quote != 0 {
-			result.WriteByte(ch)
-			i++
-			if ch == quote {
-				if i < len(query) && query[i] == quote {
-					result.WriteByte(query[i])
-					i++
-				} else {
-					quote = 0
-				}
-			}
-			continue
-		}
-		if i+1 < len(query) && ch == '-' && query[i+1] == '-' {
-			end := strings.IndexByte(query[i:], '\n')
-			if end < 0 {
-				result.WriteString(query[i:])
-				break
-			}
-			result.WriteString(query[i : i+end+1])
-			i += end + 1
-			continue
-		}
-		if i+1 < len(query) && ch == '/' && query[i+1] == '*' {
-			end := strings.Index(query[i+2:], "*/")
-			if end < 0 {
-				result.WriteString(query[i:])
-				break
-			}
-			result.WriteString(query[i : i+end+4])
-			i += end + 4
-			continue
-		}
-		// Only string literals and quoted identifiers are skipped. Brackets are
-		// not a quoted region: a subscript such as tags[$1] must bind $1.
-		if ch == '\'' || ch == '"' || ch == '`' {
-			quote = ch
-			result.WriteByte(ch)
-			i++
-			continue
+	last := 0
+	for _, reference := range scanParameters(query) {
+		result.WriteString(query[last:reference.start])
+		if reference.number < 1 || reference.number > len(params) {
+			return "", fmt.Errorf("parameter %s was not provided", query[reference.start:reference.end])
 		}
-		if ch == '$' && i+1 < len(query) && query[i+1] >= '0' && query[i+1] <= '9' {
-			end := i + 1
-			for end < len(query) && query[end] >= '0' && query[end] <= '9' {
-				end++
-			}
-			n, err := strconv.Atoi(query[i+1 : end])
-			if err != nil || n < 1 || n > len(params) {
-				return "", fmt.Errorf("parameter %s was not provided", query[i:end])
-			}
-			literal, err := parameterLiteral(params[n-1])
-			if err != nil {
-				return "", fmt.Errorf("parameter $%d: %w", n, err)
-			}
-			result.WriteString(literal)
-			i = end
-			continue
+		literal, err := parameterLiteral(params[reference.number-1])
+		if err != nil {
+			return "", fmt.Errorf("parameter $%d: %w", reference.number, err)
 		}
-		result.WriteByte(ch)
-		i++
+		result.WriteString(literal)
+		last = reference.end
 	}
+	result.WriteString(query[last:])
 	return result.String(), nil
 }
 
@@ -1182,8 +1137,8 @@ func parameterLiteral(param boundParameter) (string, error) {
 		return "NULL", nil
 	}
 	if param.format == 1 {
-		if _, isArray := arrayParamElementType(param.oid); isArray {
-			return "", fmt.Errorf("%w: binary array parameter format for OID %d; send arrays in text format", pgerror.ErrFeatureNotSupported, param.oid)
+		if element, isArray := arrayParamElementType(param.oid); isArray {
+			return binaryArrayLiteral(element, param.value)
 		}
 		if param.oid == 3802 {
 			// PostgreSQL's binary jsonb format is a version byte followed by the
@@ -1201,6 +1156,10 @@ func parameterLiteral(param boundParameter) (string, error) {
 			return "'" + strings.ReplaceAll(canonical, "'", "''") + "'::jsonb", nil
 		}
 		switch param.oid {
+		case 700, 701:
+			return binaryFloatText(param.oid, param.value)
+		case 1700:
+			return binaryNumericText(param.value)
 		case 16:
 			if len(param.value) != 1 {
 				return "", fmt.Errorf("invalid binary boolean")
@@ -1455,6 +1414,17 @@ func (c *Connection) sendEmptyQueryResponse() error {
 	return c.writeMessage(MsgEmptyQueryResponse, []byte{})
 }
 
+// sendParameterDescription reports the negotiated parameter types of a
+// prepared statement or a bound portal.
+func (c *Connection) sendParameterDescription(oids []int32) error {
+	mb := NewMessageBuilder()
+	mb.WriteInt16(int16(len(oids)))
+	for _, oid := range oids {
+		mb.WriteInt32(oid)
+	}
+	return c.writeMessage(MsgParameterDescription, mb.Bytes())
+}
+
 // sendRowDescription sends row description (column metadata)
 func (c *Connection) sendRowDescription(columns []string, columnTypes []string) error {
 	mb := NewMessageBuilder()

+ 303 - 0
pkg/pgserver/param_binary.go

@@ -0,0 +1,303 @@
+package pgserver
+
+import (
+	"encoding/binary"
+	"fmt"
+	"math"
+	"strconv"
+	"strings"
+
+	"github.com/danfragoso/pizzasql-next/pkg/executor"
+	"github.com/danfragoso/pizzasql-next/pkg/pgerror"
+)
+
+// binaryArrayLiteral decodes a PostgreSQL binary array parameter into the typed
+// array literal the executor consumes. Element text is re-encoded into array
+// input text and canonicalized, so a malformed element fails at Bind with
+// 22P02 exactly like a malformed text-format array.
+func binaryArrayLiteral(elementType string, value []byte) (string, error) {
+	text, err := binaryArrayText(elementType, value)
+	if err != nil {
+		return "", err
+	}
+	arrayType := elementType + "[]"
+	canonical, err := executor.CanonicalizeArrayText(text, arrayType)
+	if err != nil {
+		return "", err
+	}
+	return "'" + strings.ReplaceAll(canonical, "'", "''") + "'::" + arrayType, nil
+}
+
+// binaryArrayText converts a binary array parameter to PostgreSQL array text.
+// The envelope is ndim, hasnull, element OID, one dimension header, then
+// length-prefixed elements; every read is bounds-checked and any structural
+// problem is reported as invalid text input rather than a partial decode.
+func binaryArrayText(elementType string, value []byte) (string, error) {
+	if len(value) < 12 {
+		return "", pgerror.InvalidText("binary array parameter is truncated")
+	}
+	dimensions := int32(binary.BigEndian.Uint32(value[0:4]))
+	hasNull := int32(binary.BigEndian.Uint32(value[4:8]))
+	elementOID := binary.BigEndian.Uint32(value[8:12])
+	if dimensions < 0 || hasNull < 0 || hasNull > 1 {
+		return "", pgerror.InvalidText("invalid binary array header")
+	}
+	if dimensions > 1 {
+		return "", pgerror.InvalidText("multidimensional arrays are not supported")
+	}
+	expectedOID, ok := binaryArrayElementOID(elementType)
+	if !ok {
+		return "", fmt.Errorf("%w: binary array element type %s", pgerror.ErrFeatureNotSupported, elementType)
+	}
+	if elementOID != expectedOID {
+		return "", pgerror.InvalidText("binary array element OID %d does not match %s[]", elementOID, elementType)
+	}
+	position := 12
+	count := 0
+	if dimensions == 1 {
+		if position+8 > len(value) {
+			return "", pgerror.InvalidText("binary array parameter is truncated")
+		}
+		length := int32(binary.BigEndian.Uint32(value[position : position+4]))
+		if length < 0 {
+			return "", pgerror.InvalidText("invalid binary array dimension length")
+		}
+		count = int(length)
+		position += 8 // length and lower bound; elements are read in order
+	}
+	// Every element has at least its 4-byte length prefix, so a corrupt count
+	// cannot drive a long loop over bytes that are not there.
+	if count > (len(value)-position)/4 {
+		return "", pgerror.InvalidText("binary array parameter is truncated")
+	}
+	var builder strings.Builder
+	builder.WriteByte('{')
+	for i := 0; i < count; i++ {
+		if position+4 > len(value) {
+			return "", pgerror.InvalidText("binary array parameter is truncated")
+		}
+		length := int32(binary.BigEndian.Uint32(value[position : position+4]))
+		position += 4
+		if i > 0 {
+			builder.WriteByte(',')
+		}
+		if length == -1 {
+			if hasNull == 0 {
+				return "", pgerror.InvalidText("binary array has a NULL element without the null flag")
+			}
+			builder.WriteString("NULL")
+			continue
+		}
+		if length < 0 || position+int(length) > len(value) {
+			return "", pgerror.InvalidText("binary array parameter is truncated")
+		}
+		text, err := binaryArrayElementText(elementOID, value[position:position+int(length)])
+		if err != nil {
+			return "", err
+		}
+		position += int(length)
+		builder.WriteString(arrayElementLiteral(text))
+	}
+	if position != len(value) {
+		return "", pgerror.InvalidText("binary array parameter has trailing bytes")
+	}
+	builder.WriteByte('}')
+	return builder.String(), nil
+}
+
+// binaryArrayElementOID reports the PostgreSQL element OID a supported array
+// parameter type must carry in its binary envelope.
+func binaryArrayElementOID(elementType string) (uint32, bool) {
+	switch elementType {
+	case "TEXT":
+		return 25, true
+	case "VARCHAR":
+		return 1043, true
+	case "INTEGER":
+		return 23, true
+	case "BIGINT":
+		return 20, true
+	case "SMALLINT":
+		return 21, true
+	case "BOOLEAN":
+		return 16, true
+	case "REAL":
+		return 700, true
+	case "DOUBLE":
+		return 701, true
+	case "NUMERIC":
+		return 1700, true
+	default:
+		return 0, false
+	}
+}
+
+// binaryArrayElementText decodes one element to its PostgreSQL text spelling.
+func binaryArrayElementText(elementOID uint32, data []byte) (string, error) {
+	switch elementOID {
+	case 25, 1043: // text, varchar
+		return string(data), nil
+	case 16: // boolean
+		if len(data) != 1 || data[0] > 1 {
+			return "", pgerror.InvalidText("invalid binary boolean array element")
+		}
+		if data[0] == 0 {
+			return "f", nil
+		}
+		return "t", nil
+	case 21: // int2
+		if len(data) != 2 {
+			return "", pgerror.InvalidText("invalid binary int2 array element")
+		}
+		return strconv.FormatInt(int64(int16(binary.BigEndian.Uint16(data))), 10), nil
+	case 23: // int4
+		if len(data) != 4 {
+			return "", pgerror.InvalidText("invalid binary int4 array element")
+		}
+		return strconv.FormatInt(int64(int32(binary.BigEndian.Uint32(data))), 10), nil
+	case 20: // int8
+		if len(data) != 8 {
+			return "", pgerror.InvalidText("invalid binary int8 array element")
+		}
+		return strconv.FormatInt(int64(binary.BigEndian.Uint64(data)), 10), nil
+	case 700: // float4
+		if len(data) != 4 {
+			return "", pgerror.InvalidText("invalid binary float4 array element")
+		}
+		return strconv.FormatFloat(float64(math.Float32frombits(binary.BigEndian.Uint32(data))), 'g', -1, 32), nil
+	case 701: // float8
+		if len(data) != 8 {
+			return "", pgerror.InvalidText("invalid binary float8 array element")
+		}
+		return strconv.FormatFloat(math.Float64frombits(binary.BigEndian.Uint64(data)), 'g', -1, 64), nil
+	case 1700: // numeric
+		return binaryNumericText(data)
+	default:
+		return "", fmt.Errorf("%w: binary array element OID %d", pgerror.ErrFeatureNotSupported, elementOID)
+	}
+}
+
+// arrayElementLiteral quotes one element for array input text. The result is
+// still validated by CanonicalizeArrayText, so this only has to be parseable.
+func arrayElementLiteral(text string) string {
+	needsQuote := text == "" || strings.EqualFold(text, "NULL")
+	for i := 0; i < len(text) && !needsQuote; i++ {
+		switch text[i] {
+		case '{', '}', ',', '"', '\\', ' ', '\t', '\n', '\r':
+			needsQuote = true
+		}
+	}
+	if !needsQuote {
+		return text
+	}
+	var builder strings.Builder
+	builder.WriteByte('"')
+	for i := 0; i < len(text); i++ {
+		if text[i] == '"' || text[i] == '\\' {
+			builder.WriteByte('\\')
+		}
+		builder.WriteByte(text[i])
+	}
+	builder.WriteByte('"')
+	return builder.String()
+}
+
+// binaryNumericText converts PostgreSQL's binary numeric representation to its
+// exact decimal text. NUMERIC elements stay text in the engine, so the digits
+// must not pass through float64: the representation is base-10000 digits, a
+// base-10000 weight, a sign, and a display scale.
+func binaryNumericText(data []byte) (string, error) {
+	if len(data) < 8 {
+		return "", pgerror.InvalidText("binary numeric parameter is truncated")
+	}
+	ndigits := int(binary.BigEndian.Uint16(data[0:2]))
+	weight := int(int16(binary.BigEndian.Uint16(data[2:4])))
+	sign := binary.BigEndian.Uint16(data[4:6])
+	scale := int(binary.BigEndian.Uint16(data[6:8]))
+	if len(data) != 8+ndigits*2 {
+		return "", pgerror.InvalidText("binary numeric parameter has an inconsistent digit count")
+	}
+	if scale > 16383 {
+		return "", pgerror.InvalidText("binary numeric parameter has an unsupported scale")
+	}
+	digits := make([]int, ndigits)
+	for i := range digits {
+		digits[i] = int(binary.BigEndian.Uint16(data[8+i*2:]))
+		if digits[i] > 9999 {
+			return "", pgerror.InvalidText("binary numeric parameter has an invalid digit")
+		}
+	}
+	switch sign {
+	case 0x0000, 0x4000:
+	case 0xC000:
+		return "NaN", nil
+	case 0xD000:
+		return "Infinity", nil
+	case 0xF000:
+		return "-Infinity", nil
+	default:
+		return "", pgerror.InvalidText("binary numeric parameter has an invalid sign")
+	}
+
+	var integer strings.Builder
+	if weight >= 0 {
+		for position := weight; position >= 0; position-- {
+			index := weight - position
+			digit := 0
+			if index < ndigits {
+				digit = digits[index]
+			}
+			if position == weight {
+				integer.WriteString(strconv.Itoa(digit))
+			} else {
+				fmt.Fprintf(&integer, "%04d", digit)
+			}
+		}
+	}
+	integerPart := strings.TrimLeft(integer.String(), "0")
+	if integerPart == "" {
+		integerPart = "0"
+	}
+
+	var fractional strings.Builder
+	group := 1
+	for fractional.Len() < scale {
+		digit := 0
+		if index := weight + group; index >= 0 && index < ndigits {
+			digit = digits[index]
+		}
+		fmt.Fprintf(&fractional, "%04d", digit)
+		group++
+	}
+	fractionPart := fractional.String()[:scale]
+
+	var result strings.Builder
+	if sign == 0x4000 {
+		result.WriteByte('-')
+	}
+	result.WriteString(integerPart)
+	if fractionPart != "" {
+		result.WriteByte('.')
+		result.WriteString(fractionPart)
+	}
+	return result.String(), nil
+}
+
+// binaryFloatText decodes a scalar binary float4 or float8 parameter to the
+// decimal text the executor parses.
+func binaryFloatText(oid int32, value []byte) (string, error) {
+	switch oid {
+	case 700:
+		if len(value) != 4 {
+			return "", pgerror.InvalidText("invalid binary float4 parameter")
+		}
+		return strconv.FormatFloat(float64(math.Float32frombits(binary.BigEndian.Uint32(value))), 'g', -1, 32), nil
+	case 701:
+		if len(value) != 8 {
+			return "", pgerror.InvalidText("invalid binary float8 parameter")
+		}
+		return strconv.FormatFloat(math.Float64frombits(binary.BigEndian.Uint64(value)), 'g', -1, 64), nil
+	default:
+		return "", pgerror.InvalidText("unsupported binary floating-point OID %d", oid)
+	}
+}

+ 303 - 0
pkg/pgserver/param_binary_test.go

@@ -0,0 +1,303 @@
+package pgserver
+
+import (
+	"encoding/binary"
+	"math"
+	"testing"
+)
+
+// encodeBinaryArray builds a PostgreSQL binary array parameter. A nil elements
+// slice is the empty array (ndim 0); a nil element is SQL NULL.
+func encodeBinaryArray(elementOID uint32, elements [][]byte) []byte {
+	hasNull := false
+	for _, element := range elements {
+		if element == nil {
+			hasNull = true
+		}
+	}
+	var buf []byte
+	if elements == nil {
+		buf = binary.BigEndian.AppendUint32(buf, 0)
+		buf = binary.BigEndian.AppendUint32(buf, 0)
+		buf = binary.BigEndian.AppendUint32(buf, elementOID)
+		return buf
+	}
+	buf = binary.BigEndian.AppendUint32(buf, 1)
+	flags := uint32(0)
+	if hasNull {
+		flags = 1
+	}
+	buf = binary.BigEndian.AppendUint32(buf, flags)
+	buf = binary.BigEndian.AppendUint32(buf, elementOID)
+	buf = binary.BigEndian.AppendUint32(buf, uint32(len(elements)))
+	buf = binary.BigEndian.AppendUint32(buf, 1)
+	for _, element := range elements {
+		if element == nil {
+			buf = binary.BigEndian.AppendUint32(buf, 0xFFFFFFFF)
+			continue
+		}
+		buf = binary.BigEndian.AppendUint32(buf, uint32(len(element)))
+		buf = append(buf, element...)
+	}
+	return buf
+}
+
+func encodeInt2(value int16) []byte {
+	return binary.BigEndian.AppendUint16(nil, uint16(value))
+}
+
+func encodeInt4(value int32) []byte {
+	return binary.BigEndian.AppendUint32(nil, uint32(value))
+}
+
+func encodeInt8(value int64) []byte {
+	return binary.BigEndian.AppendUint64(nil, uint64(value))
+}
+
+func encodeFloat4(value float32) []byte {
+	return binary.BigEndian.AppendUint32(nil, math.Float32bits(value))
+}
+
+func encodeFloat8(value float64) []byte {
+	return binary.BigEndian.AppendUint64(nil, math.Float64bits(value))
+}
+
+// encodeNumeric mirrors PostgreSQL's binary numeric layout: digit count,
+// base-10000 weight, sign, display scale, then base-10000 digits.
+func encodeNumeric(sign uint16, weight int16, scale uint16, digits ...uint16) []byte {
+	buf := binary.BigEndian.AppendUint16(nil, uint16(len(digits)))
+	buf = binary.BigEndian.AppendUint16(buf, uint16(weight))
+	buf = binary.BigEndian.AppendUint16(buf, sign)
+	buf = binary.BigEndian.AppendUint16(buf, scale)
+	for _, digit := range digits {
+		buf = binary.BigEndian.AppendUint16(buf, digit)
+	}
+	return buf
+}
+
+// runBinaryParameter drives one extended-protocol cycle with a single
+// binary-format parameter.
+func runBinaryParameter(t *testing.T, c *Connection, conn *countingConn, sql string, oid int32, value []byte) []*Message {
+	t.Helper()
+	conn.reset()
+	parse := NewMessageBuilder()
+	parse.WriteString("s1")
+	parse.WriteString(sql)
+	parse.WriteInt16(1)
+	parse.WriteInt32(oid)
+
+	bind := NewMessageBuilder()
+	bind.WriteString("p1")
+	bind.WriteString("s1")
+	bind.WriteInt16(1)
+	bind.WriteInt16(1) // binary parameter format
+	bind.WriteInt16(1)
+	bind.WriteInt32(int32(len(value)))
+	bind.WriteBytes(value)
+	bind.WriteInt16(0)
+
+	execute := NewMessageBuilder()
+	execute.WriteString("p1")
+	execute.WriteInt32(0)
+
+	for _, msg := range []*Message{
+		{Type: MsgParse, Data: parse.Bytes()},
+		{Type: MsgBind, Data: bind.Bytes()},
+		{Type: MsgExecute, Data: execute.Bytes()},
+		{Type: MsgSync},
+	} {
+		if err := c.handleMessage(msg); err != nil {
+			t.Fatalf("handle %c: %v", msg.Type, err)
+		}
+	}
+	return readAllMessages(t, conn.bytes())
+}
+
+func TestBinaryArrayParameterElements(t *testing.T) {
+	c, conn := arrayConnection(t)
+	cases := []struct {
+		name     string
+		sql      string
+		arrayOID int32
+		value    []byte
+		want     string
+	}{
+		{
+			name: "int4", sql: `SELECT $1::INTEGER[]`, arrayOID: 1007,
+			value: encodeBinaryArray(23, [][]byte{encodeInt4(1), encodeInt4(-2)}), want: "{1,-2}",
+		},
+		{
+			name: "int4_null_element", sql: `SELECT $1::INTEGER[]`, arrayOID: 1007,
+			value: encodeBinaryArray(23, [][]byte{encodeInt4(1), nil, encodeInt4(3)}), want: "{1,NULL,3}",
+		},
+		{
+			name: "int4_empty", sql: `SELECT $1::INTEGER[]`, arrayOID: 1007,
+			value: encodeBinaryArray(23, nil), want: "{}",
+		},
+		{
+			name: "int2", sql: `SELECT $1::SMALLINT[]`, arrayOID: 1005,
+			value: encodeBinaryArray(21, [][]byte{encodeInt2(1), encodeInt2(-2)}), want: "{1,-2}",
+		},
+		{
+			name: "int8_exact", sql: `SELECT $1::BIGINT[]`, arrayOID: 1016,
+			value: encodeBinaryArray(20, [][]byte{encodeInt8(9007199254740993)}), want: "{9007199254740993}",
+		},
+		{
+			name: "bool", sql: `SELECT $1::BOOLEAN[]`, arrayOID: 1000,
+			value: encodeBinaryArray(16, [][]byte{{0}, {1}}), want: "{f,t}",
+		},
+		{
+			name: "float4", sql: `SELECT $1::REAL[]`, arrayOID: 1021,
+			value: encodeBinaryArray(700, [][]byte{encodeFloat4(1.5)}), want: "{1.5}",
+		},
+		{
+			name: "float8", sql: `SELECT $1::DOUBLE PRECISION[]`, arrayOID: 1022,
+			value: encodeBinaryArray(701, [][]byte{encodeFloat8(0.1)}), want: "{0.1}",
+		},
+		{
+			name: "text", sql: `SELECT $1::TEXT[]`, arrayOID: 1009,
+			value: encodeBinaryArray(25, [][]byte{
+				[]byte("a,b"), []byte("NULL"), []byte(""), []byte(`q"t`), []byte(`back\slash`),
+			}),
+			want: `{"a,b","NULL","","q\"t","back\\slash"}`,
+		},
+		{
+			name: "varchar", sql: `SELECT $1::VARCHAR(10)[]`, arrayOID: 1015,
+			value: encodeBinaryArray(1043, [][]byte{[]byte("x"), []byte("y z")}), want: `{x,"y z"}`,
+		},
+		{
+			name: "numeric_exact", sql: `SELECT $1::NUMERIC[]`, arrayOID: 1231,
+			value: encodeBinaryArray(1700, [][]byte{
+				encodeNumeric(0x0000, 4, 5, 1234, 5678, 9012, 3456, 7890, 1234, 5000),
+				encodeNumeric(0x4000, 0, 2, 1, 5000),
+			}),
+			want: "{12345678901234567890.12345,-1.50}",
+		},
+		{
+			name: "numeric_zero", sql: `SELECT $1::NUMERIC[]`, arrayOID: 1231,
+			value: encodeBinaryArray(1700, [][]byte{
+				encodeNumeric(0x0000, 0, 2),
+				encodeNumeric(0x0000, -1, 1, 5000),
+			}),
+			want: "{0.00,0.5}",
+		},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			messages := runBinaryParameter(t, c, conn, tc.sql, tc.arrayOID, tc.value)
+			if errMsg := findError(messages); errMsg != nil {
+				t.Fatalf("%s failed: %s (%s)", tc.sql, errorCode(errMsg), errorMessage(errMsg))
+			}
+			row := firstDataRow(t, messages)
+			if len(row) != 1 || row[0] != tc.want {
+				t.Fatalf("%s = %v, want [%s]", tc.sql, row, tc.want)
+			}
+		})
+	}
+}
+
+func TestBinaryArrayParameterMalformed(t *testing.T) {
+	c, conn := arrayConnection(t)
+	valid := encodeBinaryArray(23, [][]byte{encodeInt4(1)})
+	nullWithoutFlag := encodeBinaryArray(23, [][]byte{nil, encodeInt4(1)})
+	if nullWithoutFlag[7] != 1 {
+		t.Fatal("test fixture expected the null flag set")
+	}
+	nullWithoutFlag[7] = 0 // clear hasNull while leaving the NULL element
+	cases := []struct {
+		name  string
+		value []byte
+	}{
+		{"truncated_header", valid[:10]},
+		{"truncated_dimension", valid[:14]},
+		{"truncated_elements", valid[:len(valid)-2]},
+		{"trailing_bytes", append(append([]byte(nil), valid...), 0)},
+		{"multidimensional", encodeBinaryArray(23, [][]byte{encodeInt4(1)})},
+		{"element_oid_mismatch", encodeBinaryArray(25, [][]byte{[]byte("x")})},
+		{"null_without_flag", nullWithoutFlag},
+		{"invalid_boolean", encodeBinaryArray(16, [][]byte{{2}})},
+		{"short_int4", encodeBinaryArray(23, [][]byte{{0, 0}})},
+	}
+	// ndim = 2 is rejected before any dimension header is read.
+	multiDimensional := encodeBinaryArray(23, [][]byte{encodeInt4(1)})
+	binary.BigEndian.PutUint32(multiDimensional[0:4], 2)
+	cases[4].value = multiDimensional
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			messages := runBinaryParameter(t, c, conn, `SELECT $1::INTEGER[]`, 1007, tc.value)
+			errMsg := findError(messages)
+			if errMsg == nil {
+				t.Fatalf("%s was accepted", tc.name)
+			}
+			if code := errorCode(errMsg); code != ErrCodeInvalidText {
+				t.Fatalf("%s SQLSTATE = %q, want %q", tc.name, code, ErrCodeInvalidText)
+			}
+		})
+	}
+}
+
+// Array types the engine does not decode in binary form keep the explicit
+// feature-not-supported error.
+func TestBinaryArrayParameterUnsupportedElementType(t *testing.T) {
+	c, conn := arrayConnection(t)
+	for _, oid := range []int32{1014 /* bytea[] */, 1182 /* date[] */} {
+		messages := runBinaryParameter(t, c, conn, `SELECT $1`, oid, []byte{0, 0, 0, 0})
+		errMsg := findError(messages)
+		if errMsg == nil {
+			t.Fatalf("binary array OID %d was accepted", oid)
+		}
+		if code := errorCode(errMsg); code != ErrCodeFeatureNotSupported {
+			t.Fatalf("binary array OID %d SQLSTATE = %q, want %q", oid, code, ErrCodeFeatureNotSupported)
+		}
+	}
+}
+
+// Scalar float parameters follow the same negotiated type: pgx sends float4 and
+// float8 in binary form.
+func TestBinaryFloatParameter(t *testing.T) {
+	c, conn := arrayConnection(t)
+	for _, tc := range []struct {
+		name  string
+		sql   string
+		oid   int32
+		value []byte
+		want  string
+	}{
+		{"float4", `SELECT $1::REAL`, 700, encodeFloat4(1.5), "1.5"},
+		{"float8", `SELECT $1::DOUBLE PRECISION`, 701, encodeFloat8(0.1), "0.1"},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			messages := runBinaryParameter(t, c, conn, tc.sql, tc.oid, tc.value)
+			if errMsg := findError(messages); errMsg != nil {
+				t.Fatalf("%s failed: %s", tc.sql, errorCode(errMsg))
+			}
+			if row := firstDataRow(t, messages); len(row) != 1 || row[0] != tc.want {
+				t.Fatalf("%s = %v, want [%s]", tc.sql, row, tc.want)
+			}
+		})
+	}
+}
+
+// Scalar binary numeric parameters keep their exact decimal digits. The
+// capability document advertises binary numeric support, and NUMERIC elements
+// use the same decoder inside arrays.
+func TestBinaryNumericParameter(t *testing.T) {
+	c, conn := arrayConnection(t)
+	value := encodeNumeric(0x0000, 1, 1, 1, 2345, 5000)
+	messages := runBinaryParameter(t, c, conn, `SELECT $1::NUMERIC`, 1700, value)
+	if errMsg := findError(messages); errMsg != nil {
+		t.Fatalf("binary numeric failed: %s", errorCode(errMsg))
+	}
+	if row := firstDataRow(t, messages); len(row) != 1 || row[0] != "12345.5" {
+		t.Fatalf("binary numeric = %v, want [12345.5]", row)
+	}
+
+	messages = runBinaryParameter(t, c, conn, `SELECT $1::NUMERIC`, 1700, encodeNumeric(0x4000, -1, 1, 5000))
+	if errMsg := findError(messages); errMsg != nil {
+		t.Fatalf("negative binary numeric failed: %s", errorCode(errMsg))
+	}
+	if row := firstDataRow(t, messages); len(row) != 1 || row[0] != "-0.5" {
+		t.Fatalf("negative binary numeric = %v, want [-0.5]", row)
+	}
+}

+ 574 - 0
pkg/pgserver/param_types.go

@@ -0,0 +1,574 @@
+package pgserver
+
+import (
+	"strconv"
+	"strings"
+
+	"github.com/danfragoso/pizzasql-next/pkg/lexer"
+	"github.com/danfragoso/pizzasql-next/pkg/parser"
+	"github.com/danfragoso/pizzasql-next/pkg/storage"
+)
+
+// textParameterOID is the type a parameter falls back to when the SQL gives no
+// usable context. PostgreSQL's Describe never reports OID 0 for a parameter:
+// drivers such as pgx reject the statement client-side when the described
+// parameter count or type disagrees with the arguments they were given.
+const textParameterOID = 25
+
+// markerPrefix identifies the string literal substituted for a $n placeholder
+// while a statement is parsed for type inference. The parser has no parameter
+// node, so each placeholder becomes a uniquely named string literal whose
+// position in the AST supplies the surrounding comparison or assignment
+// context.
+const markerPrefix = "__pizzasql_parameter_"
+
+// parameterReference records one $n placeholder found by the quote- and
+// comment-aware scan that Bind uses. number is 0 when the digits do not fit in
+// an int, which Bind reports as a missing parameter.
+type parameterReference struct {
+	number int
+	start  int
+	end    int
+}
+
+// negotiateParameterOIDs builds the ParameterDescription type list for query.
+// The list length is the highest placeholder number (or the client's declared
+// count when that is larger), client-declared non-zero OIDs win, and every
+// remaining entry is inferred from the SQL or falls back to TEXT.
+func (c *Connection) negotiateParameterOIDs(query string, declared []int32) []int32 {
+	references := scanParameters(query)
+	count := len(declared)
+	for _, reference := range references {
+		if reference.number > count {
+			count = reference.number
+		}
+	}
+	oids := make([]int32, count)
+	copy(oids, declared)
+	if count == 0 {
+		return oids
+	}
+	inferred := c.inferParameterTypes(query, references)
+	for i := range oids {
+		if oids[i] != 0 {
+			continue
+		}
+		oids[i] = textParameterOID
+		if typeName, ok := inferred[i+1]; ok {
+			oids[i] = c.getOIDForType(typeName)
+		}
+	}
+	return oids
+}
+
+// inferParameterTypes returns the type name of every parameter whose SQL
+// context names one. Inference is best effort: a statement the parser does not
+// accept, or a column the catalog cannot resolve, leaves that parameter out so
+// the caller falls back to TEXT.
+func (c *Connection) inferParameterTypes(query string, references []parameterReference) map[int]string {
+	if len(references) == 0 {
+		return nil
+	}
+	marked, markers := substituteParameterMarkers(query, references)
+	stmt, err := parser.New(lexer.New(marked)).Parse()
+	if err != nil {
+		return nil
+	}
+	inferrer := &parameterInferrer{
+		conn:    c,
+		markers: markers,
+		types:   make(map[int]string),
+	}
+	inferrer.walkStatement(nil, stmt)
+	return inferrer.types
+}
+
+// scanParameters locates $n placeholders outside string literals, quoted
+// identifiers, and comments. Bind and parameter inference share it, so Describe
+// sees exactly the placeholders Bind replaces.
+func scanParameters(query string) []parameterReference {
+	var references []parameterReference
+	var quote byte
+	for i := 0; i < len(query); {
+		ch := query[i]
+		if quote != 0 {
+			i++
+			if ch == quote {
+				if i < len(query) && query[i] == quote {
+					i++
+				} else {
+					quote = 0
+				}
+			}
+			continue
+		}
+		if i+1 < len(query) && ch == '-' && query[i+1] == '-' {
+			end := strings.IndexByte(query[i:], '\n')
+			if end < 0 {
+				break
+			}
+			i += end + 1
+			continue
+		}
+		if i+1 < len(query) && ch == '/' && query[i+1] == '*' {
+			end := strings.Index(query[i+2:], "*/")
+			if end < 0 {
+				break
+			}
+			i += end + 4
+			continue
+		}
+		// Brackets are ordinary syntax: a subscript such as tags[$1] binds $1.
+		if ch == '\'' || ch == '"' || ch == '`' {
+			quote = ch
+			i++
+			continue
+		}
+		if ch == '$' && i+1 < len(query) && query[i+1] >= '0' && query[i+1] <= '9' {
+			end := i + 1
+			for end < len(query) && query[end] >= '0' && query[end] <= '9' {
+				end++
+			}
+			number, _ := strconv.Atoi(query[i+1 : end])
+			references = append(references, parameterReference{number: number, start: i, end: end})
+			i = end
+			continue
+		}
+		i++
+	}
+	return references
+}
+
+// substituteParameterMarkers replaces each placeholder with a distinct string
+// literal and reports marker text -> parameter number.
+func substituteParameterMarkers(query string, references []parameterReference) (string, map[string]int) {
+	var marked strings.Builder
+	markers := make(map[string]int, len(references))
+	last := 0
+	for i, reference := range references {
+		marker := markerPrefix + strconv.Itoa(i) + "__"
+		markers[marker] = reference.number
+		marked.WriteString(query[last:reference.start])
+		marked.WriteString("'")
+		marked.WriteString(marker)
+		marked.WriteString("'")
+		last = reference.end
+	}
+	marked.WriteString(query[last:])
+	return marked.String(), markers
+}
+
+// parameterInferrer walks one parsed statement and records the type name each
+// parameter occurrence is compared with or assigned to. The first resolved
+// occurrence of a parameter wins.
+type parameterInferrer struct {
+	conn    *Connection
+	markers map[string]int
+	types   map[int]string
+}
+
+func (in *parameterInferrer) note(marker, typeName string) {
+	number, ok := in.markers[marker]
+	if !ok || typeName == "" {
+		return
+	}
+	if _, recorded := in.types[number]; recorded {
+		return
+	}
+	in.types[number] = typeName
+}
+
+// parameterScope is one query level's table set. Column types come from the
+// storage catalog through the connection's session, so DDL staged by the
+// current transaction is visible.
+type parameterScope struct {
+	parent *parameterScope
+	tables []parameterTable
+}
+
+type parameterTable struct {
+	name   string
+	alias  string
+	schema *storage.Schema
+}
+
+func (c *Connection) parameterTableSchema(name string) *storage.Schema {
+	if name == "" {
+		return nil
+	}
+	if c.executor != nil {
+		if schema, err := c.executor.Session().GetSchema(name); err == nil {
+			return schema
+		}
+		return nil
+	}
+	if c.schema != nil {
+		if schema, err := c.schema.GetSchema(name); err == nil {
+			return schema
+		}
+	}
+	return nil
+}
+
+func (c *Connection) newParameterScope(parent *parameterScope, refs []parser.TableRef) *parameterScope {
+	scope := &parameterScope{parent: parent}
+	for _, ref := range refs {
+		scope.addTable(c, ref)
+	}
+	return scope
+}
+
+func (s *parameterScope) addTable(c *Connection, ref parser.TableRef) {
+	if ref.Subquery == nil && ref.Function == nil {
+		if schema := c.parameterTableSchema(ref.Name); schema != nil {
+			s.tables = append(s.tables, parameterTable{name: ref.Name, alias: ref.Alias, schema: schema})
+		}
+	}
+	if ref.Join != nil && ref.Join.Table != nil {
+		s.addTable(c, *ref.Join.Table)
+	}
+}
+
+// columnType resolves a column reference to its catalog type name. An
+// unqualified name is only used when exactly one table in the level has it; a
+// qualified name that names a table without the column stops the search rather
+// than falling through to an outer query.
+func (s *parameterScope) columnType(ref *parser.ColumnRef) (string, bool) {
+	for scope := s; scope != nil; scope = scope.parent {
+		var found string
+		matches, matchedTable := 0, false
+		for _, table := range scope.tables {
+			if ref.Table != "" && !strings.EqualFold(table.name, ref.Table) && !strings.EqualFold(table.alias, ref.Table) {
+				continue
+			}
+			matchedTable = true
+			for _, column := range table.schema.Columns {
+				if !strings.EqualFold(column.Name, ref.Column) {
+					continue
+				}
+				matches++
+				found = column.Type
+			}
+		}
+		if matches > 0 {
+			return found, matches == 1
+		}
+		if ref.Table != "" && matchedTable {
+			return "", false
+		}
+	}
+	return "", false
+}
+
+func tableRefName(ref *parser.TableRef) string {
+	if ref == nil {
+		return ""
+	}
+	return ref.Name
+}
+
+func schemaColumnType(schema *storage.Schema, name string) string {
+	if schema == nil {
+		return ""
+	}
+	for _, column := range schema.Columns {
+		if strings.EqualFold(column.Name, name) {
+			return column.Type
+		}
+	}
+	return ""
+}
+
+func (in *parameterInferrer) walkStatement(scope *parameterScope, stmt parser.Statement) {
+	switch s := stmt.(type) {
+	case *parser.SelectStmt:
+		in.walkSelect(scope, s)
+	case *parser.CompoundSelect:
+		in.walkSelect(scope, s.Left)
+		in.walkSelect(scope, s.Right)
+		in.walkExpr(scope, s.Limit, "")
+		in.walkExpr(scope, s.Offset, "")
+	case *parser.InsertStmt:
+		in.walkInsert(scope, s)
+	case *parser.UpdateStmt:
+		in.walkUpdate(scope, s)
+	case *parser.DeleteStmt:
+		in.walkDelete(scope, s)
+	}
+}
+
+func (in *parameterInferrer) walkSelect(parent *parameterScope, stmt *parser.SelectStmt) {
+	if stmt == nil {
+		return
+	}
+	for _, cte := range stmt.With {
+		in.walkSelect(parent, cte.Query)
+	}
+	scope := in.conn.newParameterScope(parent, stmt.From)
+	for _, column := range stmt.Columns {
+		in.walkExpr(scope, column.Expr, "")
+	}
+	in.walkExpr(scope, stmt.Where, "")
+	for _, expr := range stmt.GroupBy {
+		in.walkExpr(scope, expr, "")
+	}
+	in.walkExpr(scope, stmt.Having, "")
+	for _, item := range stmt.OrderBy {
+		in.walkExpr(scope, item.Expr, "")
+	}
+	in.walkExpr(scope, stmt.Limit, "")
+	in.walkExpr(scope, stmt.Offset, "")
+	if stmt.Compound != nil {
+		in.walkStatement(parent, stmt.Compound)
+	}
+}
+
+func (in *parameterInferrer) walkInsert(parent *parameterScope, stmt *parser.InsertStmt) {
+	var target []parser.TableRef
+	if stmt.Table != nil {
+		target = append(target, *stmt.Table)
+	}
+	schema := in.conn.parameterTableSchema(tableRefName(stmt.Table))
+	scope := in.conn.newParameterScope(parent, target)
+
+	// Without an explicit column list, VALUES positions map to the table's
+	// columns in declaration order.
+	columnTypes := make([]string, 0, len(stmt.Columns))
+	if len(stmt.Columns) == 0 {
+		if schema != nil {
+			for _, column := range schema.Columns {
+				// Generated columns are not writable and are skipped when
+				// positional values map to the table's columns.
+				if column.GeneratedExpr != "" {
+					continue
+				}
+				columnTypes = append(columnTypes, column.Type)
+			}
+		}
+	} else {
+		for _, name := range stmt.Columns {
+			columnTypes = append(columnTypes, schemaColumnType(schema, name))
+		}
+	}
+
+	for _, row := range stmt.Values {
+		for i, value := range row {
+			expected := ""
+			if i < len(columnTypes) {
+				expected = columnTypes[i]
+			}
+			in.walkExpr(scope, value, expected)
+		}
+	}
+	in.walkSelect(parent, stmt.Select)
+	for _, assignment := range stmt.ConflictUpdate {
+		in.walkExpr(scope, assignment.Value, schemaColumnType(schema, assignment.Column))
+	}
+	for _, column := range stmt.Returning {
+		in.walkExpr(scope, column.Expr, "")
+	}
+}
+
+func (in *parameterInferrer) walkUpdate(parent *parameterScope, stmt *parser.UpdateStmt) {
+	var target []parser.TableRef
+	if stmt.Table != nil {
+		target = append(target, *stmt.Table)
+	}
+	schema := in.conn.parameterTableSchema(tableRefName(stmt.Table))
+	scope := in.conn.newParameterScope(parent, append(target, stmt.From...))
+	for _, assignment := range stmt.Set {
+		in.walkExpr(scope, assignment.Value, schemaColumnType(schema, assignment.Column))
+	}
+	in.walkExpr(scope, stmt.Where, "")
+	for _, column := range stmt.Returning {
+		in.walkExpr(scope, column.Expr, "")
+	}
+}
+
+func (in *parameterInferrer) walkDelete(parent *parameterScope, stmt *parser.DeleteStmt) {
+	var target []parser.TableRef
+	if stmt.Table != nil {
+		target = append(target, *stmt.Table)
+	}
+	scope := in.conn.newParameterScope(parent, target)
+	in.walkExpr(scope, stmt.Where, "")
+	for _, column := range stmt.Returning {
+		in.walkExpr(scope, column.Expr, "")
+	}
+}
+
+// walkExpr visits expr with the type expected from its surrounding context.
+// Only the operands of comparison and containment operators, assignment
+// targets, and ANY/ALL operands supply a context; everything else recurses
+// without one.
+func (in *parameterInferrer) walkExpr(scope *parameterScope, expr parser.Expr, expected string) {
+	switch e := expr.(type) {
+	case nil:
+		return
+	case *parser.ParenExpr:
+		in.walkExpr(scope, e.Expr, expected)
+	case *parser.LiteralExpr:
+		if e.Type == lexer.TokenString {
+			in.note(e.Value, expected)
+		}
+	case *parser.CastExpr:
+		if literal, ok := markerLiteral(e.Expr); ok {
+			in.note(literal, e.Type.SQLTypeName())
+			return
+		}
+		in.walkExpr(scope, e.Expr, "")
+	case *parser.BinaryExpr:
+		if isParameterTypeOperator(e.Op) {
+			leftType, leftOK := in.exprType(scope, e.Left)
+			rightType, rightOK := in.exprType(scope, e.Right)
+			if leftOK {
+				in.walkExpr(scope, e.Right, leftType)
+			} else {
+				in.walkExpr(scope, e.Right, "")
+			}
+			if rightOK {
+				in.walkExpr(scope, e.Left, rightType)
+			} else {
+				in.walkExpr(scope, e.Left, "")
+			}
+			return
+		}
+		in.walkExpr(scope, e.Left, "")
+		in.walkExpr(scope, e.Right, "")
+	case *parser.AnyAllExpr:
+		if columnType, ok := in.exprType(scope, e.Left); ok {
+			in.walkExpr(scope, e.Array, parameterArrayType(columnType))
+		} else {
+			in.walkExpr(scope, e.Array, "")
+		}
+		in.walkExpr(scope, e.Left, "")
+	case *parser.InExpr:
+		columnType, ok := in.exprType(scope, e.Left)
+		for _, value := range e.Values {
+			if ok {
+				in.walkExpr(scope, value, columnType)
+			} else {
+				in.walkExpr(scope, value, "")
+			}
+		}
+		in.walkExpr(scope, e.Left, "")
+		in.walkSelect(scope, e.Subquery)
+	case *parser.BetweenExpr:
+		columnType, _ := in.exprType(scope, e.Left)
+		in.walkExpr(scope, e.Low, columnType)
+		in.walkExpr(scope, e.High, columnType)
+		in.walkExpr(scope, e.Left, "")
+	case *parser.IsDistinctExpr:
+		leftType, leftOK := in.exprType(scope, e.Left)
+		rightType, rightOK := in.exprType(scope, e.Right)
+		if leftOK {
+			in.walkExpr(scope, e.Right, leftType)
+		} else {
+			in.walkExpr(scope, e.Right, "")
+		}
+		if rightOK {
+			in.walkExpr(scope, e.Left, rightType)
+		} else {
+			in.walkExpr(scope, e.Left, "")
+		}
+	case *parser.IsNullExpr:
+		in.walkExpr(scope, e.Left, "")
+	case *parser.LikeExpr:
+		in.walkExpr(scope, e.Left, "")
+		in.walkExpr(scope, e.Pattern, "TEXT")
+		in.walkExpr(scope, e.Escape, "TEXT")
+	case *parser.UnaryExpr:
+		in.walkExpr(scope, e.Operand, "")
+	case *parser.FunctionCall:
+		for _, arg := range e.Args {
+			in.walkExpr(scope, arg, "")
+		}
+	case *parser.WindowExpr:
+		if e.Func != nil {
+			for _, arg := range e.Func.Args {
+				in.walkExpr(scope, arg, "")
+			}
+		}
+		for _, expr := range e.PartitionBy {
+			in.walkExpr(scope, expr, "")
+		}
+		for _, item := range e.OrderBy {
+			in.walkExpr(scope, item.Expr, "")
+		}
+	case *parser.CaseExpr:
+		in.walkExpr(scope, e.Operand, "")
+		for _, when := range e.Whens {
+			in.walkExpr(scope, when.Condition, "")
+			in.walkExpr(scope, when.Result, expected)
+		}
+		in.walkExpr(scope, e.Else, expected)
+	case *parser.ArrayExpr:
+		for _, element := range e.Elements {
+			in.walkExpr(scope, element, "")
+		}
+	case *parser.SubscriptExpr:
+		in.walkExpr(scope, e.Expr, "")
+		// An array subscript is an integer position regardless of the array's
+		// element type.
+		in.walkExpr(scope, e.Index, "INTEGER")
+	case *parser.SubqueryExpr:
+		in.walkSelect(scope, e.Query)
+	case *parser.ExistsExpr:
+		in.walkSelect(scope, e.Subquery)
+	}
+}
+
+// exprType reports the type of an expression that names one directly: a column
+// from the catalog or an explicit cast.
+func (in *parameterInferrer) exprType(scope *parameterScope, expr parser.Expr) (string, bool) {
+	switch e := expr.(type) {
+	case *parser.ParenExpr:
+		return in.exprType(scope, e.Expr)
+	case *parser.ColumnRef:
+		if scope == nil {
+			return "", false
+		}
+		return scope.columnType(e)
+	case *parser.CastExpr:
+		return e.Type.SQLTypeName(), true
+	}
+	return "", false
+}
+
+// markerLiteral reports the parameter marker text when expr is a marker,
+// looking through parentheses.
+func markerLiteral(expr parser.Expr) (string, bool) {
+	for {
+		paren, ok := expr.(*parser.ParenExpr)
+		if !ok {
+			break
+		}
+		expr = paren.Expr
+	}
+	literal, ok := expr.(*parser.LiteralExpr)
+	if !ok || literal.Type != lexer.TokenString || !strings.HasPrefix(literal.Value, markerPrefix) {
+		return "", false
+	}
+	return literal.Value, true
+}
+
+// isParameterTypeOperator reports whether an operator's operands share a type,
+// so one side can supply the other side's parameter type.
+func isParameterTypeOperator(op lexer.TokenType) bool {
+	switch op {
+	case lexer.TokenEq, lexer.TokenNeq, lexer.TokenLt, lexer.TokenLte,
+		lexer.TokenGt, lexer.TokenGte, lexer.TokenAtArrow, lexer.TokenLtAt:
+		return true
+	}
+	return false
+}
+
+// parameterArrayType returns the array type of a column's element type. An
+// array column keeps its declared array type: ANY/ALL over an array column is
+// the engine's supported form.
+func parameterArrayType(columnType string) string {
+	if _, isArray := arrayElementTypeName(columnType); isArray {
+		return columnType
+	}
+	return columnType + "[]"
+}

+ 212 - 0
pkg/pgserver/param_types_test.go

@@ -0,0 +1,212 @@
+package pgserver
+
+import (
+	"bufio"
+	"encoding/binary"
+	"reflect"
+	"testing"
+)
+
+// paramTypeConnection returns a connection whose catalog has the tables the
+// inference cases reference.
+func paramTypeConnection(t *testing.T) *Connection {
+	t.Helper()
+	c, _ := arrayConnection(t)
+	for _, sql := range []string{
+		`CREATE TABLE t (id INTEGER PRIMARY KEY, big BIGINT, name TEXT, doc JSONB)`,
+		`CREATE TABLE arr (tags TEXT[], nums INTEGER[])`,
+	} {
+		if _, err := c.executor.Execute(parseStmt(t, sql)); err != nil {
+			t.Fatalf("%s: %v", sql, err)
+		}
+	}
+	return c
+}
+
+func TestNegotiatedParameterOIDs(t *testing.T) {
+	c := paramTypeConnection(t)
+	cases := []struct {
+		name     string
+		query    string
+		declared []int32
+		want     []int32
+	}{
+		{"cast_integer", `SELECT $1::INTEGER`, nil, []int32{23}},
+		{"cast_text_array", `SELECT $1::TEXT[]`, nil, []int32{1009}},
+		{"cast_jsonb", `SELECT $1::JSONB`, nil, []int32{3802}},
+		{"comparison_integer", `SELECT id FROM t WHERE id = $1`, nil, []int32{23}},
+		{"comparison_bigint", `SELECT id FROM t WHERE big = $1`, nil, []int32{20}},
+		{"comparison_reversed", `SELECT id FROM t WHERE $1 = id`, nil, []int32{23}},
+		{"comparison_jsonb", `SELECT id FROM t WHERE doc = $1`, nil, []int32{3802}},
+		{"jsonb_containment", `SELECT id FROM t WHERE doc @> $1`, nil, []int32{3802}},
+		{"insert_column_list", `INSERT INTO t (name) VALUES ($1)`, nil, []int32{25}},
+		{"insert_positional", `INSERT INTO t VALUES ($1, $2, $3, $4)`, nil, []int32{23, 20, 25, 3802}},
+		{"update_assignment", `UPDATE t SET doc = $1 WHERE id = $2`, nil, []int32{3802, 23}},
+		{"any_integer", `SELECT id FROM t WHERE id = ANY($1)`, nil, []int32{1007}},
+		{"any_bigint", `SELECT id FROM t WHERE big = ANY($1)`, nil, []int32{1016}},
+		{"all_text", `SELECT id FROM t WHERE name <> ALL($1)`, nil, []int32{1009}},
+		{"subscript_integer", `SELECT tags[$1] FROM arr`, nil, []int32{23}},
+		{"cast_beats_column", `SELECT id FROM t WHERE id = $1::TEXT`, nil, []int32{25}},
+		{"expr_between", `SELECT id FROM t WHERE id BETWEEN $1 AND $2`, nil, []int32{23, 23}},
+		{"in_list", `SELECT id FROM t WHERE id IN ($1, $2)`, nil, []int32{23, 23}},
+		{"unresolvable", `SELECT $1`, nil, []int32{25}},
+		{"unknown_table", `SELECT id FROM missing WHERE id = $1`, nil, []int32{25}},
+		{"declared_wins", `SELECT id FROM t WHERE id = $1`, []int32{20}, []int32{20}},
+		{"declared_fills_zero", `SELECT id FROM t WHERE big = $1 AND id = $2`, []int32{20, 0}, []int32{20, 23}},
+		{"declared_extra_kept", `SELECT 1`, []int32{20, 21}, []int32{20, 21}},
+		{"placeholder_in_literal", `SELECT '$1', 1`, nil, []int32{}},
+		{"placeholder_in_comment", `SELECT id FROM t WHERE id = 1 -- $1`, nil, []int32{}},
+		{"first_occurrence_wins", `SELECT id FROM t WHERE id = $1 OR big = $1`, nil, []int32{23}},
+		{"array_column_any", `SELECT tags FROM arr WHERE tags = ANY($1)`, nil, []int32{1009}},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			got := c.negotiateParameterOIDs(tc.query, tc.declared)
+			if len(got) != len(tc.want) {
+				t.Fatalf("negotiateParameterOIDs(%q) = %v, want %v", tc.query, got, tc.want)
+			}
+			for i := range tc.want {
+				if got[i] != tc.want[i] {
+					t.Fatalf("negotiateParameterOIDs(%q) = %v, want %v", tc.query, got, tc.want)
+				}
+				if got[i] == 0 {
+					t.Fatalf("negotiateParameterOIDs(%q)[%d] = 0; a parameter type must never be unknown", tc.query, i)
+				}
+			}
+		})
+	}
+}
+
+// A statement the parser rejects still gets a parameter count from the
+// placeholder scan, and every entry falls back to TEXT.
+func TestNegotiatedParameterOIDsUnparsableStatement(t *testing.T) {
+	c := paramTypeConnection(t)
+	got := c.negotiateParameterOIDs(`SELEC $1, $2`, nil)
+	if len(got) != 2 || got[0] != 25 || got[1] != 25 {
+		t.Fatalf("unparsable statement OIDs = %v, want [25 25]", got)
+	}
+}
+
+func parameterDescriptionOIDs(t *testing.T, msg *Message) []int32 {
+	t.Helper()
+	if msg.Type != MsgParameterDescription {
+		t.Fatalf("message type = %c, want ParameterDescription", msg.Type)
+	}
+	if len(msg.Data) < 2 {
+		t.Fatal("short ParameterDescription")
+	}
+	count := int(binary.BigEndian.Uint16(msg.Data[:2]))
+	if len(msg.Data) != 2+count*4 {
+		t.Fatalf("ParameterDescription has %d bytes for %d parameters", len(msg.Data), count)
+	}
+	oids := make([]int32, count)
+	for i := range oids {
+		oids[i] = int32(binary.BigEndian.Uint32(msg.Data[2+i*4:]))
+	}
+	return oids
+}
+
+func findParameterDescription(t *testing.T, messages []*Message) []int32 {
+	t.Helper()
+	for _, message := range messages {
+		if message.Type == MsgParameterDescription {
+			return parameterDescriptionOIDs(t, message)
+		}
+	}
+	t.Fatal("no ParameterDescription message found")
+	return nil
+}
+
+// Describe reports the inferred types for a statement and for the portal bound
+// from it, so a driver that describes either one sees the same list.
+func TestDescribeReportsInferredParameterOIDs(t *testing.T) {
+	c := paramTypeConnection(t)
+	conn := &countingConn{}
+	c.conn = conn
+	c.writer = bufio.NewWriter(conn)
+
+	parse := NewMessageBuilder()
+	parse.WriteString("s1")
+	parse.WriteString(`SELECT name FROM t WHERE id = $1`)
+	parse.WriteInt16(0)
+	if err := c.handleMessage(&Message{Type: MsgParse, Data: parse.Bytes()}); err != nil {
+		t.Fatal(err)
+	}
+
+	describeStatement := NewMessageBuilder()
+	describeStatement.WriteBytes([]byte("S"))
+	describeStatement.WriteString("s1")
+	if err := c.handleMessage(&Message{Type: MsgDescribe, Data: describeStatement.Bytes()}); err != nil {
+		t.Fatal(err)
+	}
+	if err := c.writer.Flush(); err != nil {
+		t.Fatal(err)
+	}
+	statementOIDs := findParameterDescription(t, readAllMessages(t, conn.bytes()))
+	if len(statementOIDs) != 1 || statementOIDs[0] != 23 {
+		t.Fatalf("statement ParameterDescription = %v, want [23]", statementOIDs)
+	}
+
+	conn.reset()
+	bind := NewMessageBuilder()
+	bind.WriteString("p1")
+	bind.WriteString("s1")
+	bind.WriteInt16(0)
+	bind.WriteInt16(1)
+	bind.WriteInt32(1)
+	bind.WriteBytes([]byte("1"))
+	bind.WriteInt16(0)
+	if err := c.handleMessage(&Message{Type: MsgBind, Data: bind.Bytes()}); err != nil {
+		t.Fatal(err)
+	}
+	describePortal := NewMessageBuilder()
+	describePortal.WriteBytes([]byte("P"))
+	describePortal.WriteString("p1")
+	if err := c.handleMessage(&Message{Type: MsgDescribe, Data: describePortal.Bytes()}); err != nil {
+		t.Fatal(err)
+	}
+	if err := c.writer.Flush(); err != nil {
+		t.Fatal(err)
+	}
+	portalOIDs := findParameterDescription(t, readAllMessages(t, conn.bytes()))
+	if len(portalOIDs) != 1 || portalOIDs[0] != 23 {
+		t.Fatalf("portal ParameterDescription = %v, want [23]", portalOIDs)
+	}
+}
+
+// The negotiated type is what Bind decodes with: a text-format parameter that
+// used to be sniffed from its spelling now follows the described type.
+func TestBindUsesNegotiatedParameterOID(t *testing.T) {
+	c := paramTypeConnection(t)
+	conn := &countingConn{}
+	c.conn = conn
+	c.writer = bufio.NewWriter(conn)
+	if _, err := c.executor.Execute(parseStmt(t, `INSERT INTO t (id, name) VALUES (5, '007')`)); err != nil {
+		t.Fatal(err)
+	}
+	// The comparison names a TEXT column, so "007" binds as quoted text. An
+	// untyped parameter would have been sniffed as a number and matched nothing.
+	messages := runExtendedParseBindExecute(t, c, conn, `SELECT name FROM t WHERE name = $1`, 0, "007")
+	if errMsg := findError(messages); errMsg != nil {
+		t.Fatalf("bind failed: %s", errorCode(errMsg))
+	}
+	if row := firstDataRow(t, messages); len(row) != 1 || row[0] != "007" {
+		t.Fatalf("row = %v, want [007]", row)
+	}
+}
+
+// DDL staged by the current transaction is visible to inference, exactly as it
+// is to the analyzer and executor.
+func TestNegotiatedParameterOIDsSeeStagedDDL(t *testing.T) {
+	c := paramTypeConnection(t)
+	if _, err := c.executor.Execute(parseStmt(t, `BEGIN`)); err != nil {
+		t.Fatal(err)
+	}
+	if _, err := c.executor.Execute(parseStmt(t, `CREATE TABLE staged (id INTEGER PRIMARY KEY, flag BOOLEAN)`)); err != nil {
+		t.Fatal(err)
+	}
+	got := c.negotiateParameterOIDs(`SELECT id FROM staged WHERE id = $1 AND flag = $2`, nil)
+	if want := []int32{23, 16}; !reflect.DeepEqual(got, want) {
+		t.Fatalf("staged DDL parameter OIDs = %v, want %v", got, want)
+	}
+}

+ 244 - 0
pkg/pgserver/pgx_test.go

@@ -0,0 +1,244 @@
+package pgserver
+
+import (
+	"context"
+	"fmt"
+	"net"
+	"reflect"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/jackc/pgx/v5"
+
+	"github.com/danfragoso/pizzasql-next/pkg/storage"
+	"github.com/danfragoso/pizzasql-next/pkg/testkv"
+)
+
+// connectPGX starts a wire server over net.Pipe and connects pgx to it, so the
+// tests exercise the same Parse/Bind/Describe/Execute sequence a real client
+// uses. pgx's default QueryExecMode is cache_statement.
+func connectPGX(t *testing.T) *pgx.Conn {
+	t.Helper()
+	kv := testkv.New(t)
+	pool := kv.Pool(4)
+	t.Cleanup(func() { pool.Close() })
+	dbManager := storage.NewDatabaseManager(pool, &storage.DatabaseManagerConfig{
+		DefaultDatabase: "pgx_parameters",
+		AutoCreate:      true,
+	})
+
+	server, client := net.Pipe()
+	done := make(chan error, 1)
+	conn := NewConnection(server, dbManager, true)
+	go func() { done <- conn.Handle() }()
+	t.Cleanup(func() {
+		client.Close()
+		server.Close()
+		select {
+		case <-done:
+		case <-time.After(5 * time.Second):
+			t.Error("server connection did not close")
+		}
+	})
+
+	config, err := pgx.ParseConfig("postgres://tester@localhost:5432/pgx_parameters?sslmode=disable")
+	if err != nil {
+		t.Fatalf("parse pgx config: %v", err)
+	}
+	config.DialFunc = func(ctx context.Context, network, addr string) (net.Conn, error) {
+		return client, nil
+	}
+	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+	t.Cleanup(cancel)
+	pgxConn, err := pgx.ConnectConfig(ctx, config)
+	if err != nil {
+		t.Fatalf("pgx connect: %v", err)
+	}
+	t.Cleanup(func() { _ = pgxConn.Close(context.Background()) })
+	return pgxConn
+}
+
+func preparePGXSchema(t *testing.T, conn *pgx.Conn) {
+	t.Helper()
+	ctx := context.Background()
+	for _, sql := range []string{
+		`CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT, doc JSONB)`,
+		`INSERT INTO t (id, name, doc) VALUES (1, 'alpha', '{"a": 1}')`,
+		`INSERT INTO t (id, name, doc) VALUES (2, 'beta', '{"b": 2}')`,
+	} {
+		if _, err := conn.Exec(ctx, sql); err != nil {
+			t.Fatalf("%s: %v", sql, err)
+		}
+	}
+}
+
+// ParameterDescription carries the inferred OID for every parameter, which is
+// what pgx validates its argument list against.
+func TestPGXDescribesInferredParameterTypes(t *testing.T) {
+	conn := connectPGX(t)
+	preparePGXSchema(t, conn)
+	ctx := context.Background()
+	cases := []struct {
+		sql  string
+		want []uint32
+	}{
+		{`SELECT $1::INTEGER`, []uint32{23}},
+		{`SELECT $1::TEXT[]`, []uint32{1009}},
+		{`SELECT $1::JSONB`, []uint32{3802}},
+		{`SELECT id FROM t WHERE id = $1`, []uint32{23}},
+		{`SELECT name FROM t WHERE name = $1`, []uint32{25}},
+		{`SELECT id FROM t WHERE id = ANY($1)`, []uint32{1007}},
+		{`SELECT doc FROM t WHERE doc = $1::JSONB`, []uint32{3802}},
+		{`SELECT doc FROM t WHERE doc = $1`, []uint32{3802}},
+		{`INSERT INTO t (id, name) VALUES ($1, $2)`, []uint32{23, 25}},
+		{`UPDATE t SET name = $1 WHERE id = $2`, []uint32{25, 23}},
+		{`SELECT $1`, []uint32{25}},
+	}
+	for i, tc := range cases {
+		t.Run(tc.sql, func(t *testing.T) {
+			sd, err := conn.Prepare(ctx, fmt.Sprintf("described_%d", i), tc.sql)
+			if err != nil {
+				t.Fatalf("prepare: %v", err)
+			}
+			if !reflect.DeepEqual(sd.ParamOIDs, tc.want) {
+				t.Fatalf("ParamOIDs = %v, want %v", sd.ParamOIDs, tc.want)
+			}
+		})
+	}
+}
+
+// The default connection mode (cache_statement) and the other describe-based
+// modes bind and execute typed parameters.
+func TestPGXDefaultQueryModes(t *testing.T) {
+	conn := connectPGX(t)
+	preparePGXSchema(t, conn)
+	ctx := context.Background()
+	modes := []struct {
+		name string
+		mode pgx.QueryExecMode
+	}{
+		{"cache_statement", pgx.QueryExecModeCacheStatement},
+		{"cache_describe", pgx.QueryExecModeCacheDescribe},
+		{"describe_exec", pgx.QueryExecModeDescribeExec},
+	}
+	for i, tc := range modes {
+		insertID := int32(10 + i)
+		t.Run(tc.name, func(t *testing.T) {
+			var integer int32
+			if err := conn.QueryRow(ctx, `SELECT $1::INTEGER`, tc.mode, int32(41)).Scan(&integer); err != nil {
+				t.Fatalf("cast parameter: %v", err)
+			}
+			if integer != 41 {
+				t.Fatalf("cast parameter = %d, want 41", integer)
+			}
+
+			var id int32
+			if err := conn.QueryRow(ctx, `SELECT id FROM t WHERE id = $1`, tc.mode, int32(1)).Scan(&id); err != nil {
+				t.Fatalf("comparison parameter: %v", err)
+			}
+			if id != 1 {
+				t.Fatalf("comparison parameter = %d, want 1", id)
+			}
+
+			var ids []int32
+			rows, err := conn.Query(ctx, `SELECT id FROM t WHERE id = ANY($1) ORDER BY id`, tc.mode, []int32{1, 2})
+			if err != nil {
+				t.Fatalf("ANY parameter: %v", err)
+			}
+			for rows.Next() {
+				var value int32
+				if err := rows.Scan(&value); err != nil {
+					t.Fatalf("scan ANY row: %v", err)
+				}
+				ids = append(ids, value)
+			}
+			if err := rows.Err(); err != nil {
+				t.Fatalf("ANY rows: %v", err)
+			}
+			if len(ids) != 2 || ids[0] != 1 || ids[1] != 2 {
+				t.Fatalf("ANY rows = %v, want [1 2]", ids)
+			}
+
+			tag, err := conn.Exec(ctx, `INSERT INTO t (id, name) VALUES ($1, $2)`, tc.mode, insertID, "inserted")
+			if err != nil {
+				t.Fatalf("insert parameter: %v", err)
+			}
+			if tag.RowsAffected() != 1 {
+				t.Fatalf("insert affected %d rows, want 1", tag.RowsAffected())
+			}
+			var name string
+			if err := conn.QueryRow(ctx, `SELECT name FROM t WHERE id = $1`, insertID).Scan(&name); err != nil {
+				t.Fatalf("read inserted row: %v", err)
+			}
+			if name != "inserted" {
+				t.Fatalf("inserted name = %q", name)
+			}
+
+			var doc string
+			if err := conn.QueryRow(ctx, `SELECT doc FROM t WHERE doc = $1::JSONB`, tc.mode, map[string]any{"a": 1}).Scan(&doc); err != nil {
+				t.Fatalf("jsonb parameter: %v", err)
+			}
+			if doc != `{"a":1}` {
+				t.Fatalf("jsonb parameter = %q, want {\"a\":1}", doc)
+			}
+
+			var untyped string
+			if err := conn.QueryRow(ctx, `SELECT $1`, tc.mode, "hello").Scan(&untyped); err != nil {
+				t.Fatalf("untyped parameter: %v", err)
+			}
+			if untyped != "hello" {
+				t.Fatalf("untyped parameter = %q", untyped)
+			}
+		})
+	}
+}
+
+// A client-supplied Parse OID wins over inference, and Bind decodes the value
+// with it.
+func TestPGXExplicitParameterOIDWins(t *testing.T) {
+	conn := connectPGX(t)
+	ctx := context.Background()
+	sd, err := conn.PgConn().Prepare(ctx, "explicit_oid", `SELECT $1`, []uint32{20})
+	if err != nil {
+		t.Fatalf("prepare: %v", err)
+	}
+	if !reflect.DeepEqual(sd.ParamOIDs, []uint32{20}) {
+		t.Fatalf("ParamOIDs = %v, want [20]", sd.ParamOIDs)
+	}
+	result := conn.PgConn().ExecPrepared(ctx, "explicit_oid", [][]byte{[]byte("42")}, []int16{0}, []int16{0}).Read()
+	if result.Err != nil {
+		t.Fatalf("execute: %v", result.Err)
+	}
+	if len(result.Rows) != 1 || len(result.Rows[0]) != 1 || string(result.Rows[0][0]) != "42" {
+		t.Fatalf("explicit OID rows = %v, want [[42]]", result.Rows)
+	}
+}
+
+// The described parameter count is the number of placeholders, so a client
+// catches a wrong argument count before sending Bind instead of failing on an
+// empty declaration.
+func TestPGXParameterCountMismatch(t *testing.T) {
+	conn := connectPGX(t)
+	preparePGXSchema(t, conn)
+	ctx := context.Background()
+	var value int32
+	for _, tc := range []struct {
+		name string
+		args []any
+		want string
+	}{
+		{"missing_argument", nil, "expected 1 arguments, got 0"},
+		{"extra_argument", []any{int32(1), int32(2)}, "expected 1 arguments, got 2"},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			err := conn.QueryRow(ctx, `SELECT id FROM t WHERE id = $1`, tc.args...).Scan(&value)
+			if err == nil {
+				t.Fatal("query with a wrong argument count succeeded")
+			}
+			if !strings.Contains(err.Error(), tc.want) {
+				t.Fatalf("error = %v, want %q", err, tc.want)
+			}
+		})
+	}
+}

+ 1 - 1
pkg/pgserver/protocol_guards_test.go

@@ -177,7 +177,7 @@ func TestTypicalExtendedSequenceWithDescribeSucceeds(t *testing.T) {
 			row = dataRowValues(t, message)
 		}
 	}
-	if string(types) != "12nTDCZ" {
+	if string(types) != "12tnTDCZ" {
 		t.Fatalf("message sequence = %q", types)
 	}
 	if len(row) != 1 || row[0] != "7" {