Przeglądaj źródła

Merge pull request #40 from database-pizza/engine/c-param-describe

Return inferred ParameterDescription OIDs so pgx default query modes work
Danilo Fragoso 1 dzień temu
rodzic
commit
9c374e7caf

+ 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`, while portal Describe returns `NoData` without a `ParameterDescription`, as in PostgreSQL; `Execute` still sends the real `RowDescription`. A placeholder number above PostgreSQL's 65535-parameter protocol limit fails Parse with `54000`. 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 NoData without a ParameterDescription, matching PostgreSQL; Execute still emits the real RowDescription; a placeholder number above the 65535-parameter protocol limit fails Parse with 54000 before any allocation"},
 			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)
 		}

+ 34 - 14
pkg/pgserver/array_param_test.go

@@ -117,8 +117,9 @@ func TestArrayParameterOverExtendedQuery(t *testing.T) {
 		t.Fatalf("text[] ANY($1) returned %v", row)
 	}
 
-	// An unspecified parameter OID keeps the array literal untyped; ANY still
-	// reads it as array text, which is what drivers that omit OIDs rely on.
+	// With no declared OID the server infers int4[] from id = ANY($1). The
+	// text-format value still binds as array text, which is what drivers that
+	// send text parameters rely on.
 	conn.reset()
 	messages = runExtendedParseBindExecute(t, c, conn, `SELECT id FROM t WHERE id = ANY($1) ORDER BY id`, 0, "{2,3}")
 	if errMsg := findError(messages); errMsg != nil {
@@ -227,31 +228,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 +273,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)
 	}
 }
 
@@ -327,7 +346,8 @@ func TestArraySubscriptParameterBinding(t *testing.T) {
 		t.Fatalf("tags[$1] row = %v, want [b]", row)
 	}
 
-	// An untyped parameter binds the same way.
+	// A parameter with no declared OID is inferred as INTEGER from the
+	// subscript and binds the same way.
 	conn.reset()
 	messages = runExtendedParseBindExecute(t, c, conn, `SELECT tags[$1] FROM t`, 0, "3")
 	if errMsg := findError(messages); errMsg != nil {

+ 47 - 73
pkg/pgserver/connection.go

@@ -62,7 +62,10 @@ 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
 }
 
@@ -532,7 +535,11 @@ 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}
+	paramOIDs, err := c.negotiateParameterOIDs(query, oids)
+	if err != nil {
+		return c.failExtended(ErrCodeProgramLimitExceeded, err)
+	}
+	c.statements[name] = &preparedStatement{query: query, paramOIDs: paramOIDs}
 	return c.writeMessage(MsgParseComplete, nil)
 }
 
@@ -635,15 +642,13 @@ 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':
+		// PostgreSQL reports parameter types only for Describe('S'). Portal
+		// Describe returns row metadata (here NoData) without a
+		// ParameterDescription.
 		if _, ok := c.portals[name]; !ok {
 			return c.failExtended(ErrCodeInvalidParameter, fmt.Errorf("portal %q does not exist", name))
 		}
@@ -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
+	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])
 		}
-		// 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
-		}
-		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,9 +1156,13 @@ 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")
+			if len(param.value) != 1 || param.value[0] > 1 {
+				return "", pgerror.InvalidText("invalid binary boolean parameter")
 			}
 			if param.value[0] == 0 {
 				return "FALSE", nil
@@ -1455,6 +1414,21 @@ func (c *Connection) sendEmptyQueryResponse() error {
 	return c.writeMessage(MsgEmptyQueryResponse, []byte{})
 }
 
+// sendParameterDescription reports the negotiated parameter types of a
+// prepared statement. The protocol count field is 16-bit and the negotiated
+// list never exceeds maxParameterNumber, so the conversion cannot lose a count.
+func (c *Connection) sendParameterDescription(oids []int32) error {
+	if len(oids) > maxParameterNumber {
+		return fmt.Errorf("parameter description has %d entries; the protocol limit is %d", len(oids), maxParameterNumber)
+	}
+	mb := NewMessageBuilder()
+	mb.WriteInt16(int16(uint16(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()

+ 304 - 0
pkg/pgserver/param_binary.go

@@ -0,0 +1,304 @@
+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 {
+		// Text-format arrays reject nested braces with 0A000; binary arrays
+		// report the same unsupported-feature error for parity.
+		return "", fmt.Errorf("%w: multidimensional arrays are not supported", pgerror.ErrFeatureNotSupported)
+	}
+	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, 0xD000, 0xF000:
+		// The engine has no numeric representation for NaN or infinities;
+		// splicing the bare word into SQL would fail later as an unknown
+		// column, so reject the value as invalid text input here.
+		return "", pgerror.InvalidText("binary numeric parameter has an unsupported special value")
+	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)
+	}
+}

+ 414 - 0
pkg/pgserver/param_binary_test.go

@@ -0,0 +1,414 @@
+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)},
+		{"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}})},
+	}
+	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)
+			}
+		})
+	}
+}
+
+// Multidimensional binary arrays report the same unsupported-feature error as
+// their text-format equivalent.
+func TestBinaryArrayParameterMultidimensional(t *testing.T) {
+	c, conn := arrayConnection(t)
+	value := encodeBinaryArray(23, [][]byte{encodeInt4(1)})
+	binary.BigEndian.PutUint32(value[0:4], 2)
+	messages := runBinaryParameter(t, c, conn, `SELECT $1::INTEGER[]`, 1007, value)
+	errMsg := findError(messages)
+	if errMsg == nil {
+		t.Fatal("multidimensional binary array was accepted")
+	}
+	if code := errorCode(errMsg); code != ErrCodeFeatureNotSupported {
+		t.Fatalf("binary multidimensional SQLSTATE = %q, want %q", code, ErrCodeFeatureNotSupported)
+	}
+
+	messages = runExtendedParseBindExecute(t, c, conn, `SELECT $1::INTEGER[]`, 1007, "{{1}}")
+	errMsg = findError(messages)
+	if errMsg == nil {
+		t.Fatal("multidimensional text array was accepted")
+	}
+	if code := errorCode(errMsg); code != ErrCodeFeatureNotSupported {
+		t.Fatalf("text multidimensional SQLSTATE = %q, want %q", code, ErrCodeFeatureNotSupported)
+	}
+}
+
+// The engine has no numeric NaN or infinity, so both the scalar and array
+// paths must reject those binary values as invalid text instead of splicing a
+// bare NaN into SQL.
+func TestBinaryNumericSpecialValuesRejected(t *testing.T) {
+	c, conn := arrayConnection(t)
+	for _, tc := range []struct {
+		name  string
+		sql   string
+		oid   int32
+		value []byte
+	}{
+		{"scalar_nan", `SELECT $1::NUMERIC`, 1700, encodeNumeric(0xC000, 0, 0)},
+		{"scalar_infinity", `SELECT $1::NUMERIC`, 1700, encodeNumeric(0xD000, 0, 0)},
+		{"scalar_negative_infinity", `SELECT $1::NUMERIC`, 1700, encodeNumeric(0xF000, 0, 0)},
+		{"array_nan", `SELECT $1::NUMERIC[]`, 1231, encodeBinaryArray(1700, [][]byte{encodeNumeric(0xC000, 0, 0)})},
+		{"array_infinity", `SELECT $1::NUMERIC[]`, 1231, encodeBinaryArray(1700, [][]byte{encodeNumeric(0xD000, 0, 0)})},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			messages := runBinaryParameter(t, c, conn, tc.sql, tc.oid, 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)
+			}
+		})
+	}
+}
+
+// Scalar binary booleans validate the payload byte like the array element path.
+func TestBinaryBooleanParameterValidation(t *testing.T) {
+	c, conn := arrayConnection(t)
+	for _, value := range [][]byte{{}, {2}, {0, 0}} {
+		messages := runBinaryParameter(t, c, conn, `SELECT $1::BOOLEAN`, 16, value)
+		errMsg := findError(messages)
+		if errMsg == nil {
+			t.Fatalf("binary boolean %v was accepted", value)
+		}
+		if code := errorCode(errMsg); code != ErrCodeInvalidText {
+			t.Fatalf("binary boolean %v SQLSTATE = %q, want %q", value, code, ErrCodeInvalidText)
+		}
+	}
+}
+
+// Parameter formats are per parameter: one text parameter and one binary
+// parameter in the same Bind message decode independently.
+func TestMixedParameterFormats(t *testing.T) {
+	c, conn := arrayConnection(t)
+	conn.reset()
+	parse := NewMessageBuilder()
+	parse.WriteString("s1")
+	parse.WriteString(`SELECT $1::INTEGER, $2::INTEGER`)
+	parse.WriteInt16(0)
+
+	bind := NewMessageBuilder()
+	bind.WriteString("p1")
+	bind.WriteString("s1")
+	bind.WriteInt16(2) // one format code per parameter
+	bind.WriteInt16(0) // $1 text
+	bind.WriteInt16(1) // $2 binary
+	bind.WriteInt16(2)
+	bind.WriteInt32(1)
+	bind.WriteBytes([]byte("5"))
+	binaryValue := encodeInt4(7)
+	bind.WriteInt32(int32(len(binaryValue)))
+	bind.WriteBytes(binaryValue)
+	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)
+		}
+	}
+	messages := readAllMessages(t, conn.bytes())
+	if errMsg := findError(messages); errMsg != nil {
+		t.Fatalf("mixed formats failed: %s", errorCode(errMsg))
+	}
+	if row := firstDataRow(t, messages); len(row) != 2 || row[0] != "5" || row[1] != "7" {
+		t.Fatalf("mixed formats row = %v, want [5 7]", row)
+	}
+}
+
+// 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)
+	}
+}

+ 641 - 0
pkg/pgserver/param_types.go

@@ -0,0 +1,641 @@
+package pgserver
+
+import (
+	"fmt"
+	"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_"
+
+// maxParameterNumber is PostgreSQL's protocol limit on parameter numbers. A
+// Parse message addresses parameters by number and ParameterDescription counts
+// them in a 16-bit field, so a larger placeholder can never be described; it is
+// rejected before any allocation sized from the query text.
+const maxParameterNumber = 65535
+
+// parameterReference records one $n placeholder found by the quote- and
+// comment-aware scan that Bind uses. number is clamped to maxParameterNumber+1
+// when the digits exceed the protocol limit, and stays 0 for $0; Bind reports
+// both 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. A placeholder
+// number above the protocol limit is an error, so a hostile query can never
+// size the list.
+func (c *Connection) negotiateParameterOIDs(query string, declared []int32) ([]int32, error) {
+	references := scanParameters(query)
+	count := len(declared)
+	for _, reference := range references {
+		if reference.number > maxParameterNumber {
+			return nil, fmt.Errorf("parameter %s exceeds PostgreSQL's limit of %d parameters", query[reference.start:reference.end], maxParameterNumber)
+		}
+		if reference.number > count {
+			count = reference.number
+		}
+	}
+	if count > maxParameterNumber {
+		return nil, fmt.Errorf("statement declares %d parameters; PostgreSQL's limit is %d", count, maxParameterNumber)
+	}
+	oids := make([]int32, count)
+	copy(oids, declared)
+	if count == 0 {
+		return oids, nil
+	}
+	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, nil
+}
+
+// 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++
+			}
+			// Clamp at the protocol limit instead of accumulating every digit:
+			// a hostile $999... must not overflow or size an allocation.
+			number := 0
+			for _, digit := range query[i+1 : end] {
+				if number > maxParameterNumber {
+					break
+				}
+				number = number*10 + int(digit-'0')
+				if number > maxParameterNumber {
+					number = maxParameterNumber + 1
+				}
+			}
+			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, "BIGINT")
+		in.walkExpr(scope, s.Offset, "BIGINT")
+	case *parser.InsertStmt:
+		in.walkInsert(scope, s)
+	case *parser.UpdateStmt:
+		in.walkUpdate(scope, s)
+	case *parser.DeleteStmt:
+		in.walkDelete(scope, s)
+	}
+}
+
+// walkTableRefs visits the bodies and join conditions of a FROM list. The scope
+// already contains every table in the query level, so a join condition resolves
+// columns from both sides.
+func (in *parameterInferrer) walkTableRefs(scope *parameterScope, refs []parser.TableRef) {
+	for _, ref := range refs {
+		in.walkTableRef(scope, ref)
+	}
+}
+
+func (in *parameterInferrer) walkTableRef(scope *parameterScope, ref parser.TableRef) {
+	if ref.Subquery != nil {
+		in.walkSelect(scope, ref.Subquery)
+	}
+	if ref.Function != nil {
+		for _, arg := range ref.Function.Args {
+			in.walkExpr(scope, arg, "")
+		}
+	}
+	if ref.Join != nil {
+		if ref.Join.Table != nil {
+			in.walkTableRef(scope, *ref.Join.Table)
+		}
+		in.walkExpr(scope, ref.Join.Condition, "")
+	}
+}
+
+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)
+	in.walkTableRefs(scope, 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, "BIGINT")
+	in.walkExpr(scope, stmt.Offset, "BIGINT")
+	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...))
+	in.walkTableRefs(scope, 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:
+		if columnType, ok := in.exprType(scope, e.Left); ok {
+			in.walkExpr(scope, e.Low, columnType)
+			in.walkExpr(scope, e.High, columnType)
+		} else {
+			in.walkExpr(scope, e.Low, "")
+			in.walkExpr(scope, e.High, "")
+		}
+		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
+	case *parser.SubscriptExpr:
+		// An array subscript has the column's element type, so
+		// `nums[1] = $1` infers INTEGER from an INTEGER[] column.
+		if baseType, ok := in.exprType(scope, e.Expr); ok {
+			if element, isArray := arrayElementTypeName(baseType); isArray {
+				return element, 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 + "[]"
+}

+ 358 - 0
pkg/pgserver/param_types_test.go

@@ -0,0 +1,358 @@
+package pgserver
+
+import (
+	"bufio"
+	"encoding/binary"
+	"reflect"
+	"strings"
+	"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
+}
+
+// negotiatedOIDs returns negotiateParameterOIDs or fails the test.
+func negotiatedOIDs(t *testing.T, c *Connection, query string, declared []int32) []int32 {
+	t.Helper()
+	got, err := c.negotiateParameterOIDs(query, declared)
+	if err != nil {
+		t.Fatalf("negotiateParameterOIDs(%q): %v", query, err)
+	}
+	return got
+}
+
+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}},
+		{"limit_bigint", `SELECT id FROM t LIMIT $1 OFFSET $2`, nil, []int32{20, 20}},
+		{"join_condition", `SELECT t.id FROM t JOIN arr ON t.big = $1`, nil, []int32{20}},
+		{"join_subquery_body", `SELECT t.id FROM t JOIN (SELECT id FROM arr WHERE nums[1] = $1) AS x ON x.id = t.id`, nil, []int32{23}},
+		{"derived_table_body", `SELECT x.id FROM (SELECT id FROM t WHERE big = $1) AS x`, nil, []int32{20}},
+		{"subscript_comparison", `SELECT id FROM arr WHERE nums[1] = $1`, nil, []int32{23}},
+		{"ten_parameters", `SELECT $10::BIGINT, $1::INTEGER`, nil, []int32{23, 25, 25, 25, 25, 25, 25, 25, 25, 20}},
+		{"declared_conflicts_with_cast", `SELECT $1::INTEGER, id FROM t WHERE id = $2`, []int32{25, 0}, []int32{25, 23}},
+		{"declared_conflicts_with_comparison", `SELECT name FROM t WHERE name = $1 AND id = $2`, []int32{23, 0}, []int32{23, 23}},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			got := negotiatedOIDs(t, c, 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 := negotiatedOIDs(t, c, `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
+}
+
+// Statement Describe reports the negotiated parameter types; portal Describe
+// must not, matching PostgreSQL, which sends ParameterDescription only for
+// Describe('S').
+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)
+	}
+	statementMessages := readAllMessages(t, conn.bytes())
+	statementOIDs := findParameterDescription(t, statementMessages)
+	if len(statementOIDs) != 1 || statementOIDs[0] != 23 {
+		t.Fatalf("statement ParameterDescription = %v, want [23]", statementOIDs)
+	}
+	if !hasMessageType(statementMessages, MsgNoData) {
+		t.Fatal("statement Describe did not send NoData after ParameterDescription")
+	}
+
+	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)
+	}
+	portalMessages := readAllMessages(t, conn.bytes())
+	if hasMessageType(portalMessages, MsgParameterDescription) {
+		t.Fatalf("portal Describe sent ParameterDescription: %v", portalMessages)
+	}
+	if !hasMessageType(portalMessages, MsgNoData) {
+		t.Fatal("portal Describe did not send NoData")
+	}
+}
+
+func hasMessageType(messages []*Message, messageType byte) bool {
+	for _, message := range messages {
+		if message.Type == messageType {
+			return true
+		}
+	}
+	return false
+}
+
+// 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 := negotiatedOIDs(t, c, `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)
+	}
+}
+
+// parseQuery sends Parse with the declared OID list, flushes, returns the
+// messages it produced, and closes the extended cycle with Sync so the next
+// case starts from an idle connection.
+func parseQuery(t *testing.T, c *Connection, conn *countingConn, name, query string, declared []int32) []*Message {
+	t.Helper()
+	conn.reset()
+	parse := NewMessageBuilder()
+	parse.WriteString(name)
+	parse.WriteString(query)
+	parse.WriteInt16(int16(len(declared)))
+	for _, oid := range declared {
+		parse.WriteInt32(oid)
+	}
+	if err := c.handleMessage(&Message{Type: MsgParse, Data: parse.Bytes()}); err != nil {
+		t.Fatalf("handle Parse %q: %v", query, err)
+	}
+	if err := c.writer.Flush(); err != nil {
+		t.Fatalf("flush Parse %q: %v", query, err)
+	}
+	messages := readAllMessages(t, conn.bytes())
+	if err := c.handleMessage(&Message{Type: MsgSync}); err != nil {
+		t.Fatalf("handle Sync after %q: %v", query, err)
+	}
+	if err := c.writer.Flush(); err != nil {
+		t.Fatalf("flush Sync after %q: %v", query, err)
+	}
+	conn.reset()
+	return messages
+}
+
+// A placeholder number above PostgreSQL's 65535-parameter protocol limit is
+// rejected before the type list is sized, so a hostile Parse cannot allocate
+// unbounded memory or overflow the int conversion.
+func TestParameterNumberAboveProtocolLimitRejected(t *testing.T) {
+	c := paramTypeConnection(t)
+	conn := &countingConn{}
+	c.conn = conn
+	c.writer = bufio.NewWriter(conn)
+
+	for _, query := range []string{
+		`SELECT $65536`,
+		`SELECT id FROM t WHERE id = $65536`,
+		`SELECT $9223372036854775807`,
+		`SELECT $99999999999999999999999999`,
+	} {
+		t.Run(query, func(t *testing.T) {
+			messages := parseQuery(t, c, conn, "limit", query, nil)
+			errMsg := findError(messages)
+			if errMsg == nil {
+				t.Fatalf("%s was accepted", query)
+			}
+			if code := errorCode(errMsg); code != ErrCodeProgramLimitExceeded {
+				t.Fatalf("%s SQLSTATE = %q, want %q", query, code, ErrCodeProgramLimitExceeded)
+			}
+			if message := errorMessage(errMsg); !strings.Contains(message, "65535") {
+				t.Fatalf("%s message = %q, want the protocol limit", query, message)
+			}
+			if _, stored := c.statements["limit"]; stored {
+				t.Fatalf("%s stored a statement despite the rejection", query)
+			}
+		})
+	}
+}
+
+// A statement at the limit is accepted and its ParameterDescription is a
+// well-formed frame: the 16-bit count field holds 32768 and the payload is
+// exactly 2 + count*4 bytes.
+func TestParameterDescriptionAtProtocolLimit(t *testing.T) {
+	c := paramTypeConnection(t)
+	conn := &countingConn{}
+	c.conn = conn
+	c.writer = bufio.NewWriter(conn)
+
+	if messages := parseQuery(t, c, conn, "wide", `SELECT $32768`, nil); findError(messages) != nil {
+		t.Fatalf("Parse of $32768 failed: %s", errorCode(findError(messages)))
+	}
+	describe := NewMessageBuilder()
+	describe.WriteBytes([]byte("S"))
+	describe.WriteString("wide")
+	if err := c.handleMessage(&Message{Type: MsgDescribe, Data: describe.Bytes()}); err != nil {
+		t.Fatal(err)
+	}
+	if err := c.writer.Flush(); err != nil {
+		t.Fatal(err)
+	}
+	messages := readAllMessages(t, conn.bytes())
+	var description *Message
+	for _, message := range messages {
+		if message.Type == MsgParameterDescription {
+			description = message
+		}
+	}
+	if description == nil {
+		t.Fatal("Describe did not send ParameterDescription")
+	}
+	count := int(binary.BigEndian.Uint16(description.Data[:2]))
+	if count != 32768 {
+		t.Fatalf("ParameterDescription count = %d, want 32768", count)
+	}
+	if len(description.Data) != 2+count*4 {
+		t.Fatalf("ParameterDescription payload = %d bytes, want %d", len(description.Data), 2+count*4)
+	}
+	if last := int32(binary.BigEndian.Uint32(description.Data[len(description.Data)-4:])); last != 25 {
+		t.Fatalf("last parameter OID = %d, want TEXT (25)", last)
+	}
+	if err := c.handleMessage(&Message{Type: MsgSync}); err != nil {
+		t.Fatal(err)
+	}
+}

+ 327 - 0
pkg/pgserver/pgx_test.go

@@ -0,0 +1,327 @@
+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}')`,
+		`CREATE TABLE n (id INTEGER PRIMARY KEY, amount NUMERIC)`,
+		`INSERT INTO n (id, amount) VALUES (1, '1.5'), (2, '2.25')`,
+	} {
+		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 id FROM t WHERE name = ANY($1)`, []uint32{1009}},
+		{`SELECT id FROM n WHERE amount = ANY($1)`, []uint32{1231}},
+		{`SELECT id FROM t LIMIT $1 OFFSET $2`, []uint32{20, 20}},
+		{`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)
+			}
+
+			var textIDs []int32
+			rows, err = conn.Query(ctx, `SELECT id FROM t WHERE name = ANY($1) ORDER BY id`, tc.mode, []string{"alpha", "beta"})
+			if err != nil {
+				t.Fatalf("text[] parameter: %v", err)
+			}
+			for rows.Next() {
+				var value int32
+				if err := rows.Scan(&value); err != nil {
+					t.Fatalf("scan text[] row: %v", err)
+				}
+				textIDs = append(textIDs, value)
+			}
+			if err := rows.Err(); err != nil {
+				t.Fatalf("text[] rows: %v", err)
+			}
+			if len(textIDs) != 2 || textIDs[0] != 1 || textIDs[1] != 2 {
+				t.Fatalf("text[] rows = %v, want [1 2]", textIDs)
+			}
+
+			var numericIDs []int32
+			rows, err = conn.Query(ctx, `SELECT id FROM n WHERE amount = ANY($1) ORDER BY id`, tc.mode, []float64{1.5})
+			if err != nil {
+				t.Fatalf("numeric[] parameter: %v", err)
+			}
+			for rows.Next() {
+				var value int32
+				if err := rows.Scan(&value); err != nil {
+					t.Fatalf("scan numeric[] row: %v", err)
+				}
+				numericIDs = append(numericIDs, value)
+			}
+			if err := rows.Err(); err != nil {
+				t.Fatalf("numeric[] rows: %v", err)
+			}
+			if len(numericIDs) != 1 || numericIDs[0] != 1 {
+				t.Fatalf("numeric[] rows = %v, want [1]", numericIDs)
+			}
+
+			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)
+	}
+}
+
+// An explicit OID that contradicts the inferred one wins for that parameter
+// while the other parameters are still inferred; the base branch would have
+// described 0 for the unspecified parameter.
+func TestPGXExplicitParameterOIDConflict(t *testing.T) {
+	conn := connectPGX(t)
+	preparePGXSchema(t, conn)
+	ctx := context.Background()
+
+	sd, err := conn.PgConn().Prepare(ctx, "conflict_mixed", `SELECT $1::TEXT, id FROM t WHERE id = $2`, []uint32{0, 20})
+	if err != nil {
+		t.Fatalf("prepare mixed: %v", err)
+	}
+	if want := []uint32{25, 20}; !reflect.DeepEqual(sd.ParamOIDs, want) {
+		t.Fatalf("ParamOIDs = %v, want %v", sd.ParamOIDs, want)
+	}
+	result := conn.PgConn().ExecPrepared(ctx, "conflict_mixed", [][]byte{[]byte("x"), []byte("1")}, []int16{0, 0}, []int16{0}).Read()
+	if result.Err != nil {
+		t.Fatalf("execute mixed: %v", result.Err)
+	}
+	if len(result.Rows) != 1 || len(result.Rows[0]) != 2 || string(result.Rows[0][0]) != "x" || string(result.Rows[0][1]) != "1" {
+		t.Fatalf("mixed explicit rows = %v, want [[x 1]]", result.Rows)
+	}
+
+	// The client's OID beats the cast on the same parameter.
+	sd, err = conn.PgConn().Prepare(ctx, "conflict_cast", `SELECT $1::INTEGER`, []uint32{25})
+	if err != nil {
+		t.Fatalf("prepare cast: %v", err)
+	}
+	if want := []uint32{25}; !reflect.DeepEqual(sd.ParamOIDs, want) {
+		t.Fatalf("cast conflict ParamOIDs = %v, want %v", sd.ParamOIDs, want)
+	}
+	result = conn.PgConn().ExecPrepared(ctx, "conflict_cast", [][]byte{[]byte("41")}, []int16{0}, []int16{0}).Read()
+	if result.Err != nil {
+		t.Fatalf("execute cast: %v", result.Err)
+	}
+	if len(result.Rows) != 1 || len(result.Rows[0]) != 1 || string(result.Rows[0][0]) != "41" {
+		t.Fatalf("cast conflict rows = %v, want [[41]]", 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 - 0
pkg/pgserver/protocol.go

@@ -84,6 +84,7 @@ const (
 const (
 	ErrCodeSuccess              = "00000"
 	ErrCodeSyntaxError          = "42601"
+	ErrCodeProgramLimitExceeded = "54000"
 	ErrCodeUndefinedTable       = "42P01"
 	ErrCodeUndefinedColumn      = "42703"
 	ErrCodeDuplicateTable       = "42P07"

+ 2 - 0
pkg/pgserver/protocol_guards_test.go

@@ -177,6 +177,8 @@ func TestTypicalExtendedSequenceWithDescribeSucceeds(t *testing.T) {
 			row = dataRowValues(t, message)
 		}
 	}
+	// Portal Describe sends NoData only; ParameterDescription belongs to
+	// Describe('S') and PostgreSQL does not send it for a portal.
 	if string(types) != "12nTDCZ" {
 		t.Fatalf("message sequence = %q", types)
 	}