Browse Source

bound parameter numbers and fix extended-protocol edge cases

Parse now rejects a placeholder number above PostgreSQL's 65535-parameter
protocol limit with SQLSTATE 54000 before the type list is sized. The
placeholder scan clamps at the limit instead of accumulating digits, so
$9223372036854775807 can no longer panic or OOM the connection goroutine,
and ParameterDescription writes its 16-bit count field explicitly with a
guard for the negotiated list size.

Describe('P') no longer sends ParameterDescription: PostgreSQL sends it only
for Describe('S'), so a portal describe is back to NoData only while the
statement path keeps the inferred types.

Also:
- binary multidimensional arrays report 0A000, matching text-format arrays;
- binary numeric NaN/Infinity report 22P02 on the scalar and array paths
  instead of splicing a bare NaN into SQL;
- scalar binary booleans validate the payload byte like array elements;
- BETWEEN honors the untyped-column flag;
- LIMIT and OFFSET infer BIGINT;
- join conditions, derived tables, and table-function arguments are walked
  for inference, and array subscripts resolve to the element type;
- stale untyped-parameter comments and the capability/README Describe
  wording are corrected.

Tests: placeholder-limit rejection (including the int64 maximum), a
well-formed 32768-entry ParameterDescription, mixed text/binary parameter
formats, $10 among ten parameters, explicit-OID conflicts, binary text[]
and numeric[] through pgx default modes, numeric special values, and
scalar boolean validation.
Danilo Fragoso 1 ngày trước cách đây
mục cha
commit
95bacb32ca

+ 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. 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.
+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.
 
 ---

+ 1 - 1
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 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"},
+			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,

+ 5 - 3
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 {
@@ -345,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 {

+ 18 - 14
pkg/pgserver/connection.go

@@ -72,9 +72,6 @@ type preparedStatement struct {
 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
@@ -538,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: c.negotiateParameterOIDs(query, 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)
 }
 
@@ -620,7 +621,7 @@ func (c *Connection) handleBind(msg *Message) error {
 		}
 		return c.failExtended(code, err)
 	}
-	c.portals[portalName] = &portal{query: query, paramOIDs: append([]int32(nil), statement.paramOIDs...)}
+	c.portals[portalName] = &portal{query: query}
 	return c.writeMessage(MsgBindComplete, nil)
 }
 
@@ -645,13 +646,12 @@ func (c *Connection) handleDescribe(msg *Message) error {
 			return err
 		}
 	case 'P':
-		portal, ok := c.portals[name]
-		if !ok {
+		// 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))
 		}
-		if err := c.sendParameterDescription(portal.paramOIDs); err != nil {
-			return err
-		}
 	default:
 		return c.failExtended(ErrCodeProtocolViolation, fmt.Errorf("invalid Describe target"))
 	}
@@ -1161,8 +1161,8 @@ func parameterLiteral(param boundParameter) (string, error) {
 		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
@@ -1415,10 +1415,14 @@ func (c *Connection) sendEmptyQueryResponse() error {
 }
 
 // sendParameterDescription reports the negotiated parameter types of a
-// prepared statement or a bound portal.
+// 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(len(oids)))
+	mb.WriteInt16(int16(uint16(len(oids))))
 	for _, oid := range oids {
 		mb.WriteInt32(oid)
 	}

+ 8 - 7
pkg/pgserver/param_binary.go

@@ -43,7 +43,9 @@ func binaryArrayText(elementType string, value []byte) (string, error) {
 		return "", pgerror.InvalidText("invalid binary array header")
 	}
 	if dimensions > 1 {
-		return "", pgerror.InvalidText("multidimensional arrays are not supported")
+		// 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 {
@@ -229,12 +231,11 @@ func binaryNumericText(data []byte) (string, error) {
 	}
 	switch sign {
 	case 0x0000, 0x4000:
-	case 0xC000:
-		return "NaN", nil
-	case 0xD000:
-		return "Infinity", nil
-	case 0xF000:
-		return "-Infinity", nil
+	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")
 	}

+ 117 - 6
pkg/pgserver/param_binary_test.go

@@ -212,17 +212,11 @@ func TestBinaryArrayParameterMalformed(t *testing.T) {
 		{"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)
@@ -237,6 +231,123 @@ func TestBinaryArrayParameterMalformed(t *testing.T) {
 	}
 }
 
+// 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) {

+ 81 - 14
pkg/pgserver/param_types.go

@@ -1,6 +1,7 @@
 package pgserver
 
 import (
+	"fmt"
 	"strconv"
 	"strings"
 
@@ -22,9 +23,16 @@ const textParameterOID = 25
 // 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 0 when the digits do not fit in
-// an int, which Bind reports as a missing parameter.
+// 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
@@ -34,19 +42,27 @@ type parameterReference struct {
 // 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 {
+// 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
+		return oids, nil
 	}
 	inferred := c.inferParameterTypes(query, references)
 	for i := range oids {
@@ -58,7 +74,7 @@ func (c *Connection) negotiateParameterOIDs(query string, declared []int32) []in
 			oids[i] = c.getOIDForType(typeName)
 		}
 	}
-	return oids
+	return oids, nil
 }
 
 // inferParameterTypes returns the type name of every parameter whose SQL
@@ -129,7 +145,18 @@ func scanParameters(query string) []parameterReference {
 			for end < len(query) && query[end] >= '0' && query[end] <= '9' {
 				end++
 			}
-			number, _ := strconv.Atoi(query[i+1 : 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
@@ -286,8 +313,8 @@ func (in *parameterInferrer) walkStatement(scope *parameterScope, stmt parser.St
 	case *parser.CompoundSelect:
 		in.walkSelect(scope, s.Left)
 		in.walkSelect(scope, s.Right)
-		in.walkExpr(scope, s.Limit, "")
-		in.walkExpr(scope, s.Offset, "")
+		in.walkExpr(scope, s.Limit, "BIGINT")
+		in.walkExpr(scope, s.Offset, "BIGINT")
 	case *parser.InsertStmt:
 		in.walkInsert(scope, s)
 	case *parser.UpdateStmt:
@@ -297,6 +324,32 @@ func (in *parameterInferrer) walkStatement(scope *parameterScope, stmt parser.St
 	}
 }
 
+// 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
@@ -305,6 +358,7 @@ func (in *parameterInferrer) walkSelect(parent *parameterScope, stmt *parser.Sel
 		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, "")
 	}
@@ -316,8 +370,8 @@ func (in *parameterInferrer) walkSelect(parent *parameterScope, stmt *parser.Sel
 	for _, item := range stmt.OrderBy {
 		in.walkExpr(scope, item.Expr, "")
 	}
-	in.walkExpr(scope, stmt.Limit, "")
-	in.walkExpr(scope, stmt.Offset, "")
+	in.walkExpr(scope, stmt.Limit, "BIGINT")
+	in.walkExpr(scope, stmt.Offset, "BIGINT")
 	if stmt.Compound != nil {
 		in.walkStatement(parent, stmt.Compound)
 	}
@@ -376,6 +430,7 @@ func (in *parameterInferrer) walkUpdate(parent *parameterScope, stmt *parser.Upd
 	}
 	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))
 	}
@@ -454,9 +509,13 @@ func (in *parameterInferrer) walkExpr(scope *parameterScope, expr parser.Expr, e
 		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)
+		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)
@@ -531,6 +590,14 @@ func (in *parameterInferrer) exprType(scope *parameterScope, expr parser.Expr) (
 		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
 }

+ 155 - 9
pkg/pgserver/param_types_test.go

@@ -4,6 +4,7 @@ import (
 	"bufio"
 	"encoding/binary"
 	"reflect"
+	"strings"
 	"testing"
 )
 
@@ -23,6 +24,16 @@ func paramTypeConnection(t *testing.T) *Connection {
 	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 {
@@ -58,10 +69,18 @@ func TestNegotiatedParameterOIDs(t *testing.T) {
 		{"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 := c.negotiateParameterOIDs(tc.query, tc.declared)
+			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)
 			}
@@ -81,7 +100,7 @@ func TestNegotiatedParameterOIDs(t *testing.T) {
 // placeholder scan, and every entry falls back to TEXT.
 func TestNegotiatedParameterOIDsUnparsableStatement(t *testing.T) {
 	c := paramTypeConnection(t)
-	got := c.negotiateParameterOIDs(`SELEC $1, $2`, nil)
+	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)
 	}
@@ -117,8 +136,9 @@ func findParameterDescription(t *testing.T, messages []*Message) []int32 {
 	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.
+// 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{}
@@ -142,10 +162,14 @@ func TestDescribeReportsInferredParameterOIDs(t *testing.T) {
 	if err := c.writer.Flush(); err != nil {
 		t.Fatal(err)
 	}
-	statementOIDs := findParameterDescription(t, readAllMessages(t, conn.bytes()))
+	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()
@@ -168,10 +192,22 @@ func TestDescribeReportsInferredParameterOIDs(t *testing.T) {
 	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)
+	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
@@ -205,8 +241,118 @@ func TestNegotiatedParameterOIDsSeeStagedDDL(t *testing.T) {
 	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)
+	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)
+	}
+}

+ 83 - 0
pkg/pgserver/pgx_test.go

@@ -66,6 +66,8 @@ func preparePGXSchema(t *testing.T, conn *pgx.Conn) {
 		`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)
@@ -89,6 +91,9 @@ func TestPGXDescribesInferredParameterTypes(t *testing.T) {
 		{`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}},
@@ -160,6 +165,44 @@ func TestPGXDefaultQueryModes(t *testing.T) {
 				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)
@@ -215,6 +258,46 @@ func TestPGXExplicitParameterOIDWins(t *testing.T) {
 	}
 }
 
+// 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.

+ 1 - 0
pkg/pgserver/protocol.go

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

+ 3 - 1
pkg/pgserver/protocol_guards_test.go

@@ -177,7 +177,9 @@ func TestTypicalExtendedSequenceWithDescribeSucceeds(t *testing.T) {
 			row = dataRowValues(t, message)
 		}
 	}
-	if string(types) != "12tnTDCZ" {
+	// 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)
 	}
 	if len(row) != 1 || row[0] != "7" {