Selaa lähdekoodia

fix jsonb path resolution, exact equality, and protocol details

- Resolve PostgreSQL path elements by the container kind at each step, so
  numeric-looking object keys ("1", "01", "-1", "1.5") stay keys in #>, #>>,
  #-, jsonb_set, and jsonb_insert while arrays still use integer indexes.
- Recognize #- as a jsonb-producing operator in type inference, expression
  typing, and result metadata.
- Compare jsonb values by exact canonical text bytes instead of float64:
  =, <>, ordering, IS DISTINCT FROM, simple CASE, IN, DISTINCT, GROUP BY, and
  containment now agree on numbers above 2^53 and long decimals. Numeric-aware
  ORDER BY remains and is documented.
- Track consumed array matches in containment so a candidate duplicate needs a
  distinct container element (per review direction).
- Fix negative-index jsonb_insert insert_after double increment.
- Accept PostgreSQL binary jsonb parameters (version byte 0x01 + text),
  canonicalize them at Bind, and reject wrong versions/malformed bodies 22P02.
- Align jsonb_set/jsonb_insert with PostgreSQL: empty paths leave the target
  unchanged; jsonb_set appends/clamps array positions per create_if_missing;
  jsonb_insert clamps and appends; existing object keys error 22023; paths
  through scalars leave the target unchanged.
- Preserve JSON1 $ path value semantics (plain strings stay JSON strings) while
  PostgreSQL text[] paths keep strict JSON parsing.
- Canonicalize JSONB values on read (column refs, wildcard projections, RETURNING)
  so legacy rows expose canonical text to DISTINCT/GROUP BY/ORDER BY.
- Reject U+0000 and lone surrogate escapes with 22P02.
- Treat scalar JSON strings as keys for ?, report TEXT for zero-row *_text SRFs,
  report 22023 for jsonb - integer on an object, and reject comparisons such as
  doc -> 'a' = 1 with 42804.
Danilo Fragoso 1 päivä sitten
vanhempi
sitoutus
81d0783322

+ 25 - 5
pkg/analyzer/analyzer.go

@@ -1135,9 +1135,15 @@ func (a *Analyzer) analyzeBinaryExpr(e *parser.BinaryExpr) (*ExprInfo, error) {
 			return info, nil
 		}
 		info.Type = TypeText
-	case lexer.TokenArrow, lexer.TokenArrowText:
-		// JSON extraction. Like json_extract, the result follows the JSON value
-		// and is not a fixed SQL type (a JSON number extracts as a number).
+	case lexer.TokenArrow:
+		// JSON extraction. A jsonb document extracts as jsonb, so a later
+		// comparison cannot silently reinterpret the result as SQL text.
+		info.Type = TypeAny
+		if left.Type == TypeJSONB {
+			info.Type = TypeJSONB
+		}
+	case lexer.TokenArrowText:
+		// ->> returns the SQL value or text; it is not jsonb.
 		info.Type = TypeAny
 	case lexer.TokenHashArrow:
 		// JSONB path extraction returns a JSONB value.
@@ -1147,8 +1153,22 @@ func (a *Analyzer) analyzeBinaryExpr(e *parser.BinaryExpr) (*ExprInfo, error) {
 		}
 	case lexer.TokenHashArrowText:
 		info.Type = TypeText
-	case lexer.TokenAtArrow, lexer.TokenLtAt,
-		lexer.TokenQuestion, lexer.TokenQuestionPipe, lexer.TokenQuestionAmp:
+	case lexer.TokenHashMinus:
+		info.Type = TypeAny
+		if left.Type == TypeJSONB {
+			info.Type = TypeJSONB
+		}
+	case lexer.TokenAtArrow, lexer.TokenLtAt:
+		// Containment needs a jsonb operand (or text read as a jsonb document)
+		// on both sides; a number is not silently reinterpreted as JSON.
+		if !jsonbTextOperand(left.Type) || !jsonbTextOperand(right.Type) {
+			return nil, &AnalysisError{
+				Type:    ErrTypeMismatch,
+				Message: fmt.Sprintf("jsonb containment requires jsonb or text operands, got %s and %s", left.Type, right.Type),
+			}
+		}
+		info.Type = TypeBoolean
+	case lexer.TokenQuestion, lexer.TokenQuestionPipe, lexer.TokenQuestionAmp:
 		info.Type = TypeBoolean
 	default:
 		info.Type = TypeUnknown

+ 2 - 0
pkg/analyzer/jsonb_test.go

@@ -67,6 +67,8 @@ func TestAnalyzeJSONBExpressions(t *testing.T) {
 		`SELECT doc & 1 FROM docs`,
 		`SELECT doc || 1 FROM docs`,
 		`SELECT doc - doc FROM docs`,
+		`SELECT doc -> 'a' = 1 FROM docs`,
+		`SELECT doc -> 'a' @> 1 FROM docs`,
 	} {
 		err := analyze(t, a, sql)
 		if err == nil {

+ 6 - 6
pkg/capabilities/capabilities.go

@@ -217,7 +217,7 @@ func Current() *Document {
 			Boolean:  true,
 			DateTime: true,
 			JSONText: true,
-			JSONB:    Feature{Supported: true, Status: "native", SQLState: "0A000", Notes: "JSONB is a native column type stored as canonical JSON text: object keys sorted, duplicate keys last-wins, no insignificant whitespace, and numbers normalized to exact decimals (1.50 becomes 1.5, 1e2 becomes 100); the canonical form is idempotent and stable across reloads; malformed jsonb input reports 22P02, equality is canonical-text equality (key order and number spelling do not matter), and JSON/TEXT columns keep their existing text-backed JSON1 behavior; literals, ::jsonb and CAST(x AS JSONB), to_jsonb(any), and text-format jsonb parameters (OID 3802, canonicalized at Bind) produce jsonb; direct projections report OID 3802 while JSON stays TEXT; jsonpath, GIN and expression indexes over jsonb, binary jsonb parameters, and wildcard/slice/unbounded jsonb paths are rejected with 0A000; a jsonb value cannot be an array element type (0A000)"},
+			JSONB:    Feature{Supported: true, Status: "native", SQLState: "0A000", Notes: "JSONB is a native column type stored as canonical JSON text: object keys sorted, duplicate keys last-wins, no insignificant whitespace, and numbers normalized to exact decimals (1.50 becomes 1.5, 1e2 becomes 100); the canonical form is idempotent and stable across reloads; malformed jsonb input reports 22P02, equality is canonical-text equality (key order and number spelling do not matter), and JSON/TEXT columns keep their existing text-backed JSON1 behavior; literals, ::jsonb and CAST(x AS JSONB), to_jsonb(any), and text-format and PostgreSQL binary-format jsonb parameters (OID 3802) produce jsonb, canonicalized at Bind; direct projections report OID 3802 while JSON stays TEXT; jsonpath, GIN and expression indexes over jsonb, and wildcard/slice/unbounded jsonb paths are rejected with 0A000; a jsonb value cannot be an array element type (0A000)"},
 			Arrays:   Feature{Supported: true, Status: "one_dimensional", SQLState: "0A000", Notes: "column types TEXT[], VARCHAR(n)[], INTEGER[], BIGINT[], SMALLINT[], BOOLEAN[], REAL[], DOUBLE PRECISION[], and NUMERIC[] in CREATE TABLE and ALTER TABLE ADD COLUMN; values are PostgreSQL array text ({a,b},NULL) stored canonically, with NULL and the empty array distinct; literals, ARRAY[...], ::type[] and CAST(x AS type[]), 1-based arr[n], array_length(arr, 1), and unnest(array) as the single FROM table function are supported; multidimensional arrays, arrays of other element types, and ANY/ALL over subqueries are rejected with 0A000; malformed array input reports 22P02 and a non-integer subscript or array_length dimension reports 42804; NUMERIC[] and DECIMAL[] elements are validated but stored as their exact text, so digits above 2^53 and trailing zeros survive, and NUMERIC comparisons are exact (1.10 = 1.1, and digits above 2^53 are not rounded); column types survive derived tables, CTEs, and a derived table or CTE joined on the right for subscripts and comparisons (CHECK constraints and generated columns evaluate through the stateless evaluator without a table schema, so array subscripts there keep the element text); UNIQUE indexes and primary keys still use the canonical raw text, so 1.10 and 1.1 are distinct index keys even though they compare equal"},
 			RowID:    Feature{Supported: true, Status: "auto_assigned", Notes: "rowid/oid/_rowid_ are BIGINT over the wire; monotonic when the engine assigns the rowid, stable across UPDATE, not monotonic for client-assigned descending INTEGER PRIMARY KEY values"},
 		},
@@ -236,12 +236,12 @@ func Current() *Document {
 			InvalidInput:   "json_extract/json_set and ->/->> on text throw 'malformed JSON'; json_valid returns 0; jsonb input and casts report 22P02",
 			JSONB: JSONB{
 				Canonicalization: "canonical JSON text: object keys sorted byte-wise, duplicate object keys last-wins, no insignificant whitespace, numbers exact normalized decimals (1.50 becomes 1.5, 1e2 becomes 100; no exponent, no trailing fractional zeros, negative zero becomes 0); canonicalization is idempotent and every write path stores the canonical bytes, so a reload reads back the same text",
-				Ordering:         "jsonb equality is canonical-text equality, so key order and number spelling are irrelevant; DISTINCT and GROUP BY use the canonical stored value; ORDER BY follows the engine's existing value comparison on canonical text: when both values parse as numbers they compare numerically, and every other pair compares byte-wise",
-				Operators:        Feature{Supported: true, Status: "supported", SQLState: "42804", Notes: "-> and ->> extract a member/element ($ path also accepted); #> and #>> extract by a text[] path (or a $ path); @> and <@ test containment with PostgreSQL's object/array/scalar rules; ? tests an object key or top-level string array element; ?| and ?& test a text[] of keys; || merges objects, concatenates arrays, and wraps non-arrays as single-element arrays; - deletes an object key, array index, or text[] of keys/elements; #- deletes a path; NULL operands return NULL, missing keys return SQL NULL (a JSON null member is a jsonb 'null' for -> and SQL NULL for ->>), and @> sees a JSON null member as a value; a text-only @> or <@ is rejected with 42804; the HTTP /query API substitutes ? textually, so a statement using the ? operator cannot also use ? positional parameters there"},
-				Functions:        Feature{Supported: true, Status: "supported", SQLState: "22023", Notes: "jsonb_typeof, jsonb_array_length, jsonb_strip_nulls, jsonb_pretty, jsonb_build_object, jsonb_build_array, to_jsonb, jsonb_set, jsonb_insert; the JSON1 jsonb_* aliases (jsonb_set, jsonb_insert, jsonb_replace, jsonb_remove with $ paths) keep their previous behavior and return canonical jsonb; wrong document kinds report 22023 and malformed input 22P02; the JSON (text) and JSON1 function surface is unchanged"},
+				Ordering:         "jsonb equality and comparison operators compare exact canonical text bytes, so key order and number spelling are irrelevant but 1 and 1.0000000000000000001 are distinct and integers above 2^53 are not rounded; DISTINCT and GROUP BY use the canonical value (JSONB columns are canonicalized on read too); ORDER BY follows the engine's existing value comparison on canonical text, where both-numeric pairs compare numerically and every other pair compares byte-wise",
+				Operators:        Feature{Supported: true, Status: "supported", SQLState: "42804", Notes: "-> and ->> extract a member/element ($ path also accepted); #> and #>> extract by a text[] path (or a $ path); @> and <@ test containment with PostgreSQL's object/array/scalar rules; ? tests an object key or top-level string array element; ?| and ?& test a text[] of keys; || merges objects, concatenates arrays, and wraps non-arrays as single-element arrays; - deletes an object key, array index, or text[] of keys/elements; #- deletes a path; NULL operands return NULL, missing keys return SQL NULL (a JSON null member is a jsonb 'null' for -> and SQL NULL for ->>), and @> sees a JSON null member as a value; array containment matches each candidate element to a distinct container element; a text-only @> or <@ is rejected with 42804; - with an integer on an object reports 22023; the HTTP /query API substitutes ? textually, so a statement using the ? operator cannot also use ? positional parameters there"},
+				Functions:        Feature{Supported: true, Status: "supported", SQLState: "22023", Notes: "jsonb_typeof, jsonb_array_length, jsonb_strip_nulls, jsonb_pretty, jsonb_build_object, jsonb_build_array, to_jsonb, jsonb_set, jsonb_insert; jsonb_set with an empty path leaves the target unchanged, jsonb_set appends a missing array position and clamps a too-low index when create_if_missing is true, jsonb_insert clamps and appends array positions, and jsonb_insert onto an existing object key reports 22023 (cannot replace existing key); the JSON1 jsonb_* aliases (jsonb_set, jsonb_insert, jsonb_replace, jsonb_remove with $ paths) keep their previous value behavior and return canonical jsonb; wrong document kinds report 22023 and malformed input 22P02; the JSON (text) and JSON1 function surface is unchanged"},
 				SetReturning:     Feature{Supported: true, Status: "single_from_item", SQLState: "0A000", Notes: "jsonb_each, jsonb_each_text, jsonb_array_elements, jsonb_array_elements_text, and jsonb_object_keys are supported only as the single FROM item (no joins); calling one in a projection is rejected with 0A000; a NULL document produces no rows"},
-				Parameters:       Feature{Supported: true, Status: "text_format", SQLState: "0A000", Notes: "text-format OID 3802 parameters are canonicalized at Bind, so an invalid document fails with 22P02 before execution and operators see a real jsonb value; binary-format jsonb parameters are rejected with 0A000; OID 114 (JSON) behavior is unchanged"},
-				Rejected:         Feature{Supported: false, Status: "rejected", SQLState: "0A000", Notes: "jsonpath (jsonb_path_*), the @? and @@ jsonpath operators, GIN indexes, expression indexes whose result is jsonb, binary jsonb parameters, wildcard/slice/unbounded jsonb paths, and jsonb array element types"},
+				Parameters:       Feature{Supported: true, Status: "text_and_binary", SQLState: "22P02", Notes: "text-format OID 3802 parameters are canonicalized at Bind; PostgreSQL binary jsonb parameters (version byte 0x01 followed by the document text, which pgx sends by default) are accepted and canonicalized, while a wrong version or malformed body is 22P02; an invalid document fails at Bind and operators see a real jsonb value; OID 114 (JSON) behavior is unchanged"},
+				Rejected:         Feature{Supported: false, Status: "rejected", SQLState: "0A000", Notes: "jsonpath (jsonb_path_*), the @? and @@ jsonpath operators, GIN indexes, expression indexes whose result is jsonb, wildcard/slice/unbounded jsonb paths, and jsonb array element types"},
 			},
 		},
 

+ 7 - 5
pkg/capabilities/capabilities_test.go

@@ -248,7 +248,7 @@ func TestJSONBIsAdvertised(t *testing.T) {
 			t.Errorf("json.jsonb.canonicalization does not mention %q", want)
 		}
 	}
-	for _, want := range []string{"canonical-text equality", "DISTINCT", "ORDER BY", "numeric"} {
+	for _, want := range []string{"exact canonical text bytes", "DISTINCT", "ORDER BY", "numeric"} {
 		if !strings.Contains(jsonb.Ordering, want) {
 			t.Errorf("json.jsonb.ordering does not mention %q", want)
 		}
@@ -277,16 +277,18 @@ func TestJSONBIsAdvertised(t *testing.T) {
 			t.Errorf("json.jsonb.set_returning notes do not mention %q", want)
 		}
 	}
-	if !jsonb.Parameters.Supported || jsonb.Parameters.Status != "text_format" {
+	if !jsonb.Parameters.Supported || jsonb.Parameters.Status != "text_and_binary" {
 		t.Fatalf("json.jsonb.parameters = %+v", jsonb.Parameters)
 	}
-	if !strings.Contains(jsonb.Parameters.Notes, "3802") || !strings.Contains(jsonb.Parameters.Notes, "0A000") {
-		t.Fatalf("json.jsonb.parameters notes = %q", jsonb.Parameters.Notes)
+	for _, want := range []string{"3802", "0x01", "pgx", "22P02"} {
+		if !strings.Contains(jsonb.Parameters.Notes, want) {
+			t.Fatalf("json.jsonb.parameters notes do not mention %q: %q", want, jsonb.Parameters.Notes)
+		}
 	}
 	if jsonb.Rejected.Supported || jsonb.Rejected.SQLState != pgerror.CodeFeatureNotSupported {
 		t.Fatalf("json.jsonb.rejected = %+v", jsonb.Rejected)
 	}
-	for _, want := range []string{"jsonpath", "GIN", "expression indexes", "binary", "wildcard"} {
+	for _, want := range []string{"jsonpath", "GIN", "expression indexes", "wildcard"} {
 		if !strings.Contains(jsonb.Rejected.Notes, want) {
 			t.Errorf("json.jsonb.rejected notes do not mention %q", want)
 		}

+ 11 - 1
pkg/executor/arrays.go

@@ -635,6 +635,14 @@ func (e *Executor) arrayEqualityType(leftExpr, rightExpr parser.Expr) (string, b
 // equality); everything else keeps the engine's existing comparison rules.
 func (e *Executor) compareExprValues(op lexer.TokenType, leftExpr, rightExpr parser.Expr, left, right interface{}) (bool, error) {
 	if e.isJSONBExpr(leftExpr) || e.isJSONBExpr(rightExpr) {
+		// A jsonb comparison must not silently reinterpret a SQL number or
+		// boolean as a JSON value; PostgreSQL has no such operator.
+		if leftExpr != nil && e.isJSONBExpr(leftExpr) && !jsonbComparisonValue(left) {
+			return false, pgerror.DatatypeMismatch("jsonb comparison requires jsonb or text operands")
+		}
+		if rightExpr != nil && e.isJSONBExpr(rightExpr) && !jsonbComparisonValue(right) {
+			return false, pgerror.DatatypeMismatch("jsonb comparison requires jsonb or text operands")
+		}
 		// jsonb equality and ordering are defined on the canonical form, so a
 		// document with a different key order or number spelling compares equal.
 		leftText, err := CanonicalizeJSONB(toString(left))
@@ -645,7 +653,9 @@ func (e *Executor) compareExprValues(op lexer.TokenType, leftExpr, rightExpr par
 		if err != nil {
 			return false, err
 		}
-		return comparisonOperatorResult(op, compare(leftText, rightText))
+		// Compare exact canonical bytes; the general compare() helper would round
+		// numbers through float64 and disagree with =, DISTINCT, and @>.
+		return comparisonOperatorResult(op, strings.Compare(leftText, rightText))
 	}
 	if op == lexer.TokenEq || op == lexer.TokenNeq {
 		if typeName, ok := e.arrayEqualityType(leftExpr, rightExpr); ok {

+ 31 - 15
pkg/executor/executor.go

@@ -1365,7 +1365,11 @@ func (e *Executor) executeSelect(stmt *parser.SelectStmt) (*Result, error) {
 								if !ok {
 									val = row[c.Name]
 								}
-								values = append(values, val)
+								canonical, canonErr := canonicalizeJSONBReadValue(c.Type, val)
+								if canonErr != nil {
+									return nil, canonErr
+								}
+								values = append(values, canonical)
 							}
 						}
 					}
@@ -1374,7 +1378,11 @@ func (e *Executor) executeSelect(stmt *parser.SelectStmt) (*Result, error) {
 					// oid/rowid/_rowid_ is an ordinary column here, not the hidden
 					// rowid alias, so use its own value key.
 					for _, c := range schema.Columns {
-						values = append(values, row[c.Name])
+						canonical, canonErr := canonicalizeJSONBReadValue(c.Type, row[c.Name])
+						if canonErr != nil {
+							return nil, canonErr
+						}
+						values = append(values, canonical)
 					}
 				}
 			} else if col.TableStar != "" {
@@ -1387,7 +1395,11 @@ func (e *Executor) executeSelect(stmt *parser.SelectStmt) (*Result, error) {
 					if !ok {
 						val = row[c.Name]
 					}
-					values = append(values, val)
+					canonical, canonErr := canonicalizeJSONBReadValue(c.Type, val)
+					if canonErr != nil {
+						return nil, canonErr
+					}
+					values = append(values, canonical)
 				}
 			} else if we, ok := col.Expr.(*parser.WindowExpr); ok {
 				values = append(values, windowValues[we][rowIdx])
@@ -5436,22 +5448,22 @@ func (e *Executor) evalColumnRef(ref *parser.ColumnRef, row storage.Row) (interf
 		if e.outerRow != nil {
 			// Step 1-2: qualified lookup in outer row
 			if val, ok := e.outerRow[ref.Table+"."+ref.Column]; ok {
-				return val, nil
+				return e.canonicalizeJSONBColumnValue(ref, val)
 			}
 			for k, v := range e.outerRow {
 				if strings.EqualFold(k, ref.Table+"."+ref.Column) {
-					return v, nil
+					return e.canonicalizeJSONBColumnValue(ref, v)
 				}
 			}
 		}
 
 		// Step 3-4: qualified lookup in current row
 		if val, ok := row[ref.Table+"."+ref.Column]; ok {
-			return val, nil
+			return e.canonicalizeJSONBColumnValue(ref, val)
 		}
 		for k, v := range row {
 			if strings.EqualFold(k, ref.Table+"."+ref.Column) {
-				return v, nil
+				return e.canonicalizeJSONBColumnValue(ref, v)
 			}
 		}
 
@@ -5461,11 +5473,11 @@ func (e *Executor) evalColumnRef(ref *parser.ColumnRef, row storage.Row) (interf
 		// but no "t1.b").
 		if e.outerRow != nil {
 			if val, ok := e.outerRow[ref.Column]; ok {
-				return val, nil
+				return e.canonicalizeJSONBColumnValue(ref, val)
 			}
 			for k, v := range e.outerRow {
 				if strings.EqualFold(k, ref.Column) {
-					return v, nil
+					return e.canonicalizeJSONBColumnValue(ref, v)
 				}
 			}
 		}
@@ -5474,22 +5486,22 @@ func (e *Executor) evalColumnRef(ref *parser.ColumnRef, row storage.Row) (interf
 	// Step 6: unqualified fallback in current row (handles unqualified refs and
 	// single-table queries like "SELECT t1.a FROM t1" where rows have plain keys).
 	if val, ok := row[ref.Column]; ok {
-		return val, nil
+		return e.canonicalizeJSONBColumnValue(ref, val)
 	}
 	for k, v := range row {
 		if strings.EqualFold(k, ref.Column) {
-			return v, nil
+			return e.canonicalizeJSONBColumnValue(ref, v)
 		}
 	}
 
 	// For unqualified refs with an outer row context (ref.Table == "").
 	if e.outerRow != nil && ref.Table == "" {
 		if val, ok := e.outerRow[ref.Column]; ok {
-			return val, nil
+			return e.canonicalizeJSONBColumnValue(ref, val)
 		}
 		for k, v := range e.outerRow {
 			if strings.EqualFold(k, ref.Column) {
-				return v, nil
+				return e.canonicalizeJSONBColumnValue(ref, v)
 			}
 		}
 	}
@@ -5500,7 +5512,7 @@ func (e *Executor) evalColumnRef(ref *parser.ColumnRef, row storage.Row) (interf
 	// runs last.
 	if storage.IsRowIDColumn(ref.Column) {
 		if val, ok := row["_rowid_"]; ok {
-			return val, nil
+			return e.canonicalizeJSONBColumnValue(ref, val)
 		}
 	}
 
@@ -6272,7 +6284,11 @@ func (e *Executor) evalCaseExpr(expr *parser.CaseExpr, row storage.Row) (interfa
 			if operand == nil {
 				continue
 			}
-			match = compare(operand, cond) == 0
+			equal, cmpErr := e.compareExprValues(lexer.TokenEq, expr.Operand, when.Condition, operand, cond)
+			if cmpErr != nil {
+				return nil, cmpErr
+			}
+			match = equal
 		} else {
 			// Searched CASE: CASE WHEN cond THEN ... — NULL condition is falsy
 			match = toBool(cond)

+ 33 - 9
pkg/executor/json.go

@@ -118,8 +118,10 @@ type jsonPathSeg struct {
 	key      string
 	isIndex  bool
 	index    int
-	appendOp bool // [#] — append for insert/set
-	fromEnd  bool // negative index
+	appendOp bool   // [#] — append for insert/set
+	fromEnd  bool   // negative index
+	dynamic  bool   // PostgreSQL text[] path element resolved by container kind
+	element  string // raw dynamic element text
 }
 
 // parseJSONPath parses a JSON1 path expression such as $.a.b[0] or $[#].
@@ -179,34 +181,50 @@ func parseJSONPath(path string) ([]jsonPathSeg, error) {
 
 // jsonLookup walks a decoded tree to the node addressed by segs.
 func jsonLookup(root interface{}, segs []jsonPathSeg) (interface{}, bool) {
+	node, ok, _ := jsonLookupE(root, segs)
+	return node, ok
+}
+
+// jsonLookupE walks a decoded tree to the node addressed by segs. It differs
+// from jsonLookup only in reporting a malformed PostgreSQL path element (a
+// non-integer step on an array). A step that does not resolve is reported as
+// not found, which is the jsonb #> NULL result for a mismatched path.
+func jsonLookupE(root interface{}, segs []jsonPathSeg) (interface{}, bool, error) {
 	cur := root
-	for _, seg := range segs {
+	for _, rawSeg := range segs {
+		seg, resolved, err := resolveJSONBPathSegment(rawSeg, cur, false)
+		if err != nil {
+			return nil, false, err
+		}
+		if !resolved {
+			return nil, false, nil
+		}
 		if seg.isIndex {
 			arr, ok := cur.([]interface{})
 			if !ok {
-				return nil, false
+				return nil, false, nil
 			}
 			idx := seg.index
 			if seg.fromEnd {
 				idx = len(arr) + idx
 			}
 			if idx < 0 || idx >= len(arr) {
-				return nil, false
+				return nil, false, nil
 			}
 			cur = arr[idx]
 			continue
 		}
 		obj, ok := cur.(map[string]interface{})
 		if !ok {
-			return nil, false
+			return nil, false, nil
 		}
 		val, ok := obj[seg.key]
 		if !ok {
-			return nil, false
+			return nil, false, nil
 		}
 		cur = val
 	}
-	return cur, true
+	return cur, true, nil
 }
 
 // jsonApplyPatch applies json_set/insert/replace mutations to a decoded tree.
@@ -670,7 +688,13 @@ func jsonRemove(root interface{}, segs []jsonPathSeg) (interface{}, error) {
 	if len(segs) == 0 {
 		return root, nil
 	}
-	seg := segs[0]
+	seg, resolved, err := resolveJSONBPathSegment(segs[0], root, true)
+	if err != nil {
+		return nil, err
+	}
+	if !resolved {
+		return root, nil
+	}
 	last := len(segs) == 1
 	if seg.isIndex {
 		arr, ok := root.([]interface{})

+ 259 - 50
pkg/executor/jsonb.go

@@ -59,6 +59,9 @@ func decodeJSONB(text string) (interface{}, error) {
 	if !utf8.ValidString(text) {
 		return nil, invalidJSONBInput(text)
 	}
+	if err := validateJSONBUnicodeEscapes(text); err != nil {
+		return nil, err
+	}
 	dec := json.NewDecoder(strings.NewReader(text))
 	dec.UseNumber()
 	var node interface{}
@@ -72,6 +75,81 @@ func decodeJSONB(text string) (interface{}, error) {
 	return node, nil
 }
 
+// validateJSONBUnicodeEscapes rejects escapes that encoding/json would accept
+// by rewriting them: U+0000 and unpaired UTF-16 surrogates. PostgreSQL rejects
+// both in jsonb input with 22P02 instead of silently producing U+FFFD.
+func validateJSONBUnicodeEscapes(text string) error {
+	inString := false
+	for i := 0; i < len(text); i++ {
+		c := text[i]
+		if !inString {
+			if c == '"' {
+				inString = true
+			}
+			continue
+		}
+		if c == '"' {
+			inString = false
+			continue
+		}
+		if c != '\\' {
+			continue
+		}
+		if i+1 >= len(text) {
+			return invalidJSONBInput(text)
+		}
+		i++
+		if text[i] != 'u' {
+			continue
+		}
+		if i+4 >= len(text) {
+			return invalidJSONBInput(text)
+		}
+		value, ok := parseJSONBHex4(text[i+1 : i+5])
+		if !ok {
+			return invalidJSONBInput(text)
+		}
+		i += 4
+		switch {
+		case value == 0:
+			return invalidJSONBInput(text)
+		case value >= 0xD800 && value <= 0xDBFF:
+			// A high surrogate must be followed by a low surrogate escape.
+			if i+7 > len(text) || text[i+1] != '\\' || text[i+2] != 'u' {
+				return invalidJSONBInput(text)
+			}
+			low, ok := parseJSONBHex4(text[i+3 : i+7])
+			if !ok || low < 0xDC00 || low > 0xDFFF {
+				return invalidJSONBInput(text)
+			}
+			i += 6
+		case value >= 0xDC00 && value <= 0xDFFF:
+			return invalidJSONBInput(text)
+		}
+	}
+	return nil
+}
+
+func parseJSONBHex4(text string) (uint32, bool) {
+	var value uint32
+	for i := 0; i < 4; i++ {
+		digit := text[i]
+		var nibble uint32
+		switch {
+		case digit >= '0' && digit <= '9':
+			nibble = uint32(digit - '0')
+		case digit >= 'a' && digit <= 'f':
+			nibble = uint32(digit-'a') + 10
+		case digit >= 'A' && digit <= 'F':
+			nibble = uint32(digit-'A') + 10
+		default:
+			return 0, false
+		}
+		value = value<<4 | nibble
+	}
+	return value, true
+}
+
 // encodeJSONB writes a decoded JSON tree in canonical form. pretty selects the
 // four-space indented jsonb_pretty rendering.
 func encodeJSONB(node interface{}, pretty bool) (string, error) {
@@ -380,7 +458,7 @@ func (e *Executor) isJSONBExpr(expr parser.Expr) bool {
 		return strings.EqualFold(strings.TrimSpace(typed.Type.Name), "JSONB")
 	case *parser.BinaryExpr:
 		switch typed.Op {
-		case lexer.TokenArrow, lexer.TokenHashArrow:
+		case lexer.TokenArrow, lexer.TokenHashArrow, lexer.TokenHashMinus:
 			return e.isJSONBExpr(typed.Left)
 		case lexer.TokenConcat:
 			return e.isJSONBExpr(typed.Left) || e.isJSONBExpr(typed.Right)
@@ -417,6 +495,12 @@ func (e *Executor) evalJSONBBinary(op lexer.TokenType, leftExpr, rightExpr parse
 		if left == nil || right == nil {
 			return nil, true, nil
 		}
+		if (leftJSONB && !jsonbComparisonValue(right)) || (rightJSONB && !jsonbComparisonValue(left)) {
+			return nil, true, pgerror.DatatypeMismatch("jsonb comparison requires jsonb or text operands")
+		}
+		// jsonb equality and ordering compare the exact canonical bytes. Using
+		// the engine's numeric-aware compare here would round through float64 and
+		// report 1 = 1.0000000000000000001 or collapse integers above 2^53.
 		leftText, err := CanonicalizeJSONB(toString(left))
 		if err != nil {
 			return nil, true, err
@@ -425,8 +509,7 @@ func (e *Executor) evalJSONBBinary(op lexer.TokenType, leftExpr, rightExpr parse
 		if err != nil {
 			return nil, true, err
 		}
-		comparison := compare(leftText, rightText)
-		result, err := comparisonOperatorResult(op, comparison)
+		result, err := comparisonOperatorResult(op, strings.Compare(leftText, rightText))
 		return result, true, err
 	}
 
@@ -497,6 +580,49 @@ func (e *Executor) evalJSONBBinary(op lexer.TokenType, leftExpr, rightExpr parse
 	return nil, false, nil
 }
 
+// jsonbComparisonValue reports whether a runtime value can be read as a jsonb
+// document on the other side of a comparison. SQL numbers and booleans are not
+// silently reinterpreted as JSON (PostgreSQL has no jsonb = integer operator).
+func jsonbComparisonValue(value interface{}) bool {
+	switch value.(type) {
+	case string, jsonText, []byte:
+		return true
+	default:
+		return false
+	}
+}
+
+// canonicalizeJSONBReadValue canonicalizes a JSONB-typed value on read so a
+// legacy row written before native JSONB still presents canonical text to
+// projections, DISTINCT, GROUP BY, and ORDER BY.
+func canonicalizeJSONBReadValue(typeName string, value interface{}) (interface{}, error) {
+	if value == nil || !strings.EqualFold(strings.TrimSpace(typeName), "JSONB") {
+		return value, nil
+	}
+	text, ok := value.(string)
+	if !ok {
+		if jsonValue, isJSON := value.(jsonText); isJSON {
+			text = string(jsonValue)
+		} else {
+			return value, nil
+		}
+	}
+	return CanonicalizeJSONB(text)
+}
+
+// canonicalizeJSONBColumnValue applies read canonicalization to a column
+// reference. It is a no-op for columns whose declared type is not JSONB.
+func (e *Executor) canonicalizeJSONBColumnValue(ref *parser.ColumnRef, value interface{}) (interface{}, error) {
+	if ref == nil || value == nil {
+		return value, nil
+	}
+	typeName, ok := e.resolveColumnType(ref.Table, ref.Column)
+	if !ok {
+		return value, nil
+	}
+	return canonicalizeJSONBReadValue(typeName, value)
+}
+
 // jsonbArrowPath resolves the right operand of -> and ->> for a jsonb document.
 // A $ path is validated like every other jsonb path, so wildcards and slices
 // are rejected with 0A000 rather than misinterpreted as member names.
@@ -556,7 +682,10 @@ func jsonbPathExtract(document, path interface{}, scalar bool) (interface{}, err
 	if err != nil {
 		return nil, err
 	}
-	found, ok := jsonLookup(node, segs)
+	found, ok, err := jsonLookupE(node, segs)
+	if err != nil {
+		return nil, err
+	}
 	if !ok {
 		return nil, nil
 	}
@@ -636,6 +765,9 @@ func jsonbKeyExists(left, right interface{}) (interface{}, error) {
 			}
 		}
 		return false, nil
+	case string:
+		// A scalar JSON string counts as the key itself: '"x"' ? 'x' is true.
+		return value == key, nil
 	default:
 		return false, nil
 	}
@@ -707,7 +839,10 @@ func (e *Executor) jsonbDeleteValue(leftExpr, rightExpr parser.Expr, left, right
 	case int64:
 		array, ok := node.([]interface{})
 		if !ok {
-			return nil, pgerror.DatatypeMismatch("cannot delete an array element from a jsonb %s", jsonbNodeTypeName(node))
+			if _, isObject := node.(map[string]interface{}); isObject {
+				return nil, pgerror.InvalidParameter("cannot delete from object using integer index")
+			}
+			return nil, pgerror.InvalidParameter("cannot delete an array element from a jsonb %s", jsonbNodeTypeName(node))
 		}
 		return encodeJSONB(jsonbDeleteIndex(array, int(value)), false)
 	case int:
@@ -826,10 +961,15 @@ func jsonbContains(container, candidate interface{}) bool {
 	case []interface{}:
 		containerArray, ok := container.([]interface{})
 		if ok {
+			used := make([]bool, len(containerArray))
 			for _, candidateElement := range candidateValue {
 				matched := false
-				for _, containerElement := range containerArray {
+				for i, containerElement := range containerArray {
+					if used[i] {
+						continue
+					}
 					if jsonbContains(containerElement, candidateElement) {
+						used[i] = true
 						matched = true
 						break
 					}
@@ -925,8 +1065,10 @@ func jsonbKeyListArg(value interface{}) ([]string, error) {
 }
 
 // parseJSONBArrayPath parses PostgreSQL text[] path text into steps. A path
-// element that looks like an integer addresses an array index; everything else
-// is an object key.
+// element is kept as raw text because its meaning depends on the container it
+// is applied to: an object key, or an integer array index. Numeric-looking
+// object keys such as "1", "01", "-1", and "1.5" therefore stay keys when the
+// container is an object.
 func parseJSONBArrayPath(text string) ([]jsonPathSeg, error) {
 	elements, err := parseArrayLiteral(text)
 	if err != nil {
@@ -937,15 +1079,39 @@ func parseJSONBArrayPath(text string) ([]jsonPathSeg, error) {
 		if element == nil {
 			return nil, pgerror.InvalidParameter("jsonb path element must not be NULL")
 		}
-		if index, convErr := strconv.Atoi(*element); convErr == nil {
-			segs = append(segs, jsonPathSeg{isIndex: true, index: index, fromEnd: index < 0})
-			continue
-		}
-		segs = append(segs, jsonPathSeg{key: *element})
+		segs = append(segs, jsonPathSeg{dynamic: true, element: *element})
 	}
 	return segs, nil
 }
 
+// resolveJSONBPathSegment converts a PostgreSQL path element into a concrete
+// object key or array index for the container it is applied to. Concrete steps
+// (JSON1 $ paths and index/key steps) pass through unchanged. resolved is false
+// when container is a scalar or missing; callers decide whether that is a
+// not-found, an unchanged target, or an error. strict selects whether a
+// non-integer element on an array is an error (jsonb path mutation and #-) or a
+// non-match (jsonb #>).
+func resolveJSONBPathSegment(seg jsonPathSeg, container interface{}, strict bool) (jsonPathSeg, bool, error) {
+	if !seg.dynamic {
+		return seg, true, nil
+	}
+	switch container.(type) {
+	case map[string]interface{}:
+		return jsonPathSeg{key: seg.element}, true, nil
+	case []interface{}:
+		index, err := strconv.Atoi(seg.element)
+		if err != nil {
+			if strict {
+				return seg, false, pgerror.InvalidText("path element %q is not an integer", seg.element)
+			}
+			return seg, false, nil
+		}
+		return jsonPathSeg{isIndex: true, index: index, fromEnd: index < 0}, true, nil
+	default:
+		return seg, false, nil
+	}
+}
+
 // parseJSONBJSONPath parses a JSON1 $ path for jsonb use and rejects the
 // jsonpath shapes the engine cannot execute (wildcards, slices, unbounded
 // expansion) with SQLSTATE 0A000.
@@ -985,14 +1151,22 @@ func unsupportedJSONPathShape(path string) bool {
 	return false
 }
 
-// jsonbSetPG implements PostgreSQL's jsonb_set path semantics. All steps except
-// the last must exist; the last step replaces an existing member or, when
-// create is true, adds a missing object key or appends at the array end.
+// jsonbSetPG implements PostgreSQL's jsonb_set path semantics. An empty path
+// leaves the target unchanged. All steps except the last must exist and be
+// containers; missing keys or a scalar on the way leave the target unchanged. A
+// missing array position at the last step is appended when create is true, and
+// an index before the start is clamped to the first position.
 func jsonbSetPG(node interface{}, segs []jsonPathSeg, value interface{}, create bool) (interface{}, error) {
 	if len(segs) == 0 {
-		return value, nil
+		return node, nil
+	}
+	segment, resolved, err := resolveJSONBPathSegment(segs[0], node, true)
+	if err != nil {
+		return nil, err
+	}
+	if !resolved {
+		return nil, pgerror.InvalidParameter("cannot set path in scalar")
 	}
-	segment := segs[0]
 	if len(segs) > 1 {
 		child, exists, err := jsonbPathChild(node, segment)
 		if err != nil {
@@ -1001,6 +1175,13 @@ func jsonbSetPG(node interface{}, segs []jsonPathSeg, value interface{}, create
 		if !exists {
 			return node, nil
 		}
+		switch child.(type) {
+		case map[string]interface{}, []interface{}:
+		default:
+			// PostgreSQL leaves the target unchanged when an intermediate step
+			// names a scalar rather than a container.
+			return node, nil
+		}
 		updated, err := jsonbSetPG(child, segs[1:], value, create)
 		if err != nil {
 			return nil, err
@@ -1011,7 +1192,7 @@ func jsonbSetPG(node interface{}, segs []jsonPathSeg, value interface{}, create
 	switch container := node.(type) {
 	case map[string]interface{}:
 		if segment.isIndex {
-			return nil, pgerror.InvalidParameter("jsonb_set path element %d does not identify an object key", segment.index)
+			return nil, pgerror.InvalidText("path element is not an object key")
 		}
 		if _, exists := container[segment.key]; !exists && !create {
 			return node, nil
@@ -1024,16 +1205,21 @@ func jsonbSetPG(node interface{}, segs []jsonPathSeg, value interface{}, create
 		return out, nil
 	case []interface{}:
 		if !segment.isIndex {
-			return nil, pgerror.InvalidParameter("jsonb_set path element %q does not identify an array index", segment.key)
+			return nil, pgerror.InvalidText("path element %q is not an integer", segment.key)
 		}
 		index := segment.index
 		if index < 0 {
 			index += len(container)
 		}
-		if index < 0 || index > len(container) {
-			return nil, pgerror.InvalidParameter("jsonb_set path array index %d is out of range", segment.index)
+		if index < 0 {
+			if !create {
+				return node, nil
+			}
+			out := make([]interface{}, 0, len(container)+1)
+			out = append(out, value)
+			return append(out, container...), nil
 		}
-		if index == len(container) {
+		if index >= len(container) {
 			if !create {
 				return node, nil
 			}
@@ -1044,18 +1230,25 @@ func jsonbSetPG(node interface{}, segs []jsonPathSeg, value interface{}, create
 		out[index] = value
 		return out, nil
 	default:
-		return nil, pgerror.InvalidParameter("cannot set a jsonb path inside a %s", jsonbNodeTypeName(node))
+		return nil, pgerror.InvalidParameter("cannot set path in scalar")
 	}
 }
 
 // jsonbInsertPG implements PostgreSQL's jsonb_insert path semantics: array
-// elements are inserted before or after an index, and an object key is inserted
-// only when it is absent.
+// elements are inserted before or after an index (clamped into range), and an
+// object key is inserted only when it is absent. An existing object key is an
+// error, matching PostgreSQL.
 func jsonbInsertPG(node interface{}, segs []jsonPathSeg, value interface{}, after bool) (interface{}, error) {
 	if len(segs) == 0 {
 		return node, nil
 	}
-	segment := segs[0]
+	segment, resolved, err := resolveJSONBPathSegment(segs[0], node, true)
+	if err != nil {
+		return nil, err
+	}
+	if !resolved {
+		return nil, pgerror.InvalidParameter("cannot set path in scalar")
+	}
 	if len(segs) > 1 {
 		child, exists, err := jsonbPathChild(node, segment)
 		if err != nil {
@@ -1064,6 +1257,11 @@ func jsonbInsertPG(node interface{}, segs []jsonPathSeg, value interface{}, afte
 		if !exists {
 			return node, nil
 		}
+		switch child.(type) {
+		case map[string]interface{}, []interface{}:
+		default:
+			return node, nil
+		}
 		updated, err := jsonbInsertPG(child, segs[1:], value, after)
 		if err != nil {
 			return nil, err
@@ -1074,10 +1272,10 @@ func jsonbInsertPG(node interface{}, segs []jsonPathSeg, value interface{}, afte
 	switch container := node.(type) {
 	case map[string]interface{}:
 		if segment.isIndex {
-			return nil, pgerror.InvalidParameter("jsonb_insert path element %d does not identify an object key", segment.index)
+			return nil, pgerror.InvalidText("path element is not an object key")
 		}
 		if _, exists := container[segment.key]; exists {
-			return node, nil
+			return nil, pgerror.InvalidParameter("cannot replace existing key")
 		}
 		out := make(map[string]interface{}, len(container)+1)
 		for key, item := range container {
@@ -1087,20 +1285,23 @@ func jsonbInsertPG(node interface{}, segs []jsonPathSeg, value interface{}, afte
 		return out, nil
 	case []interface{}:
 		if !segment.isIndex {
-			return nil, pgerror.InvalidParameter("jsonb_insert path element %q does not identify an array index", segment.key)
+			return nil, pgerror.InvalidText("path element %q is not an integer", segment.key)
 		}
 		index := segment.index
 		if index < 0 {
 			index += len(container)
-			if after {
-				index++
-			}
+		}
+		if index < 0 {
+			index = 0
+		}
+		if index > len(container) {
+			index = len(container)
 		}
 		if after {
 			index++
 		}
-		if index < 0 || index > len(container) {
-			return nil, pgerror.InvalidParameter("jsonb_insert path array index %d is out of range", segment.index)
+		if index > len(container) {
+			index = len(container)
 		}
 		out := make([]interface{}, 0, len(container)+1)
 		out = append(out, container[:index]...)
@@ -1108,7 +1309,7 @@ func jsonbInsertPG(node interface{}, segs []jsonPathSeg, value interface{}, afte
 		out = append(out, container[index:]...)
 		return out, nil
 	default:
-		return nil, pgerror.InvalidParameter("cannot insert a jsonb path inside a %s", jsonbNodeTypeName(node))
+		return nil, pgerror.InvalidParameter("cannot set path in scalar")
 	}
 }
 
@@ -1421,13 +1622,6 @@ func (e *Executor) evalJSONBPatch(name string, fn *parser.FunctionCall, args []i
 		return nil, pgerror.Unsupported("%s() accepts the optional insert_after/create_if_missing argument only with a PostgreSQL text[] path; a $ path uses the JSON1 form", strings.ToLower(name))
 	}
 
-	newValueNode, err := jsonbNodeFromArg(args[2])
-	if err != nil {
-		return nil, err
-	}
-	if args[2] == nil {
-		return nil, nil
-	}
 	flag := false
 	if len(args) == 4 {
 		if args[3] == nil {
@@ -1437,7 +1631,9 @@ func (e *Executor) evalJSONBPatch(name string, fn *parser.FunctionCall, args []i
 	}
 
 	if !pgPath {
-		// JSON1 form: every path/value pair is applied in order.
+		// JSON1 form: every path/value pair is applied in order. A plain SQL
+		// string becomes a JSON string, which is the behavior this engine exposed
+		// before JSONB became native; a jsonb-typed value embeds its document.
 		mode := "set"
 		switch name {
 		case "JSONB_INSERT":
@@ -1453,12 +1649,14 @@ func (e *Executor) evalJSONBPatch(name string, fn *parser.FunctionCall, args []i
 			if pathErr != nil {
 				return nil, pathErr
 			}
-			patchValue, valueErr := jsonbNodeFromArg(args[i+1])
-			if valueErr != nil {
-				return nil, valueErr
+			var patchValue interface{}
+			if e.isJSONBExpr(functionArgAt(fn, i+1)) {
+				patchValue, err = jsonbNodeFromArg(args[i+1])
+			} else {
+				patchValue = jsonArgToNode(args[i+1])
 			}
-			if args[i+1] == nil {
-				return nil, nil
+			if err != nil {
+				return nil, err
 			}
 			node, err = jsonApplyPatch(node, segs, patchValue, mode)
 			if err != nil {
@@ -1468,6 +1666,13 @@ func (e *Executor) evalJSONBPatch(name string, fn *parser.FunctionCall, args []i
 		return jsonbEncodeNode(node)
 	}
 
+	if args[2] == nil {
+		return nil, nil
+	}
+	newValueNode, err := jsonbNodeFromArg(args[2])
+	if err != nil {
+		return nil, err
+	}
 	segs, err := jsonbPathArg(args[1])
 	if err != nil {
 		return nil, err
@@ -1499,10 +1704,14 @@ func jsonbTableFunction(name string, value interface{}) (columns, types []string
 	}
 	if value == nil {
 		switch strings.ToLower(name) {
-		case "jsonb_each", "jsonb_each_text":
+		case "jsonb_each":
 			return []string{"key", "value"}, []string{"TEXT", "JSONB"}, nil, nil
+		case "jsonb_each_text":
+			return []string{"key", "value"}, []string{"TEXT", "TEXT"}, nil, nil
 		case "jsonb_object_keys":
 			return []string{"jsonb_object_keys"}, []string{"TEXT"}, nil, nil
+		case "jsonb_array_elements_text":
+			return []string{"value"}, []string{"TEXT"}, nil, nil
 		default:
 			return []string{"value"}, []string{"JSONB"}, nil, nil
 		}

+ 296 - 13
pkg/executor/jsonb_test.go

@@ -6,6 +6,7 @@ import (
 	"strings"
 	"testing"
 
+	"github.com/danfragoso/pizzasql-next/pkg/analyzer"
 	"github.com/danfragoso/pizzasql-next/pkg/pgerror"
 	"github.com/danfragoso/pizzasql-next/pkg/storage"
 )
@@ -33,9 +34,12 @@ func jsonbErrorCode(t *testing.T, err error) string {
 		return pgerror.CodeFeatureNotSupported
 	case errors.Is(err, pgerror.ErrDatatypeMismatch):
 		return pgerror.CodeDatatypeMismatch
-	default:
-		return "other:" + err.Error()
 	}
+	var analysisErr *analyzer.AnalysisError
+	if errors.As(err, &analysisErr) && analysisErr.Type == analyzer.ErrTypeMismatch {
+		return pgerror.CodeDatatypeMismatch
+	}
+	return "other:" + err.Error()
 }
 
 func TestJSONBCanonicalization(t *testing.T) {
@@ -245,7 +249,7 @@ func TestJSONBContainmentAndExistence(t *testing.T) {
 		{`SELECT '{"a":1}'::jsonb ? 'b'`, false},
 		{`SELECT '[1,"x"]'::jsonb ? 'x'`, true},
 		{`SELECT '[1,"x"]'::jsonb ? '1'`, false},
-		{`SELECT '"x"'::jsonb ? 'x'`, false},
+		{`SELECT '"x"'::jsonb ? 'x'`, true},
 		{`SELECT 'null'::jsonb ? 'x'`, false},
 		{`SELECT '{"a":1,"b":2}'::jsonb ?| '{b,c}'`, true},
 		{`SELECT '{"a":1,"b":2}'::jsonb ?| '{c,d}'`, false},
@@ -301,8 +305,8 @@ func TestJSONBConcatAndDelete(t *testing.T) {
 	if got := jsonbValue(t, e, `SELECT 'a' || 'b'`); got != "ab" {
 		t.Fatalf("text concatenation = %v", got)
 	}
-	if _, err := execSQL(e, `SELECT '{"a":1}'::jsonb - 1`); jsonbErrorCode(t, err) != pgerror.CodeDatatypeMismatch {
-		t.Errorf("jsonb - integer on object error = %v, want 42804", err)
+	if _, err := execSQL(e, `SELECT '{"a":1}'::jsonb - 1`); jsonbErrorCode(t, err) != pgerror.CodeInvalidParameter {
+		t.Errorf("jsonb - integer on object error = %v, want 22023", err)
 	}
 }
 
@@ -338,19 +342,33 @@ func TestJSONBScalarFunctions(t *testing.T) {
 		{`SELECT jsonb_set('{"a":{"b":1}}', '{a,b}', '2')`, `{"a":{"b":2}}`},
 		{`SELECT jsonb_set('{"a":{"b":1}}', '{x,b}', '2')`, `{"a":{"b":1}}`},
 		{`SELECT jsonb_set('[1,2]', '{2}', '3')`, `[1,2,3]`},
+		{`SELECT jsonb_set('[1,2]', '{5}', '3')`, `[1,2,3]`},
+		{`SELECT jsonb_set('[1,2]', '{5}', '3', false)`, `[1,2]`},
+		{`SELECT jsonb_set('[1,2]', '{-3}', '9')`, `[9,1,2]`},
+		{`SELECT jsonb_set('[1,2]', '{-3}', '9', false)`, `[1,2]`},
+		{`SELECT jsonb_set('{"a":1}', '{}', '2')`, `{"a":1}`},
+		{`SELECT jsonb_insert('{"a":1}', '{}', '2')`, `{"a":1}`},
+		{`SELECT jsonb_set('{"a":1}', '{a,b}', '2')`, `{"a":1}`},
+		{`SELECT jsonb_insert('{"a":1}', '{a,b}', '2')`, `{"a":1}`},
 		{`SELECT jsonb_set('{"a":1}', NULL, '2')`, nil},
 		{`SELECT jsonb_insert('{"a":1}', NULL, '2')`, nil},
 		{`SELECT jsonb_insert('{"a":[0,1,2]}', '{a,1}', '"n"')`, `{"a":[0,"n",1,2]}`},
 		{`SELECT jsonb_insert('{"a":[0,1,2]}', '{a,1}', '"n"', true)`, `{"a":[0,1,"n",2]}`},
 		{`SELECT jsonb_insert('{"a":1}', '{b}', '2')`, `{"a":1,"b":2}`},
-		{`SELECT jsonb_insert('{"a":1}', '{a}', '2')`, `{"a":1}`},
+		{`SELECT jsonb_insert('[0,1,2]', '{5}', '9')`, `[0,1,2,9]`},
+		{`SELECT jsonb_insert('[0,1,2]', '{-3}', '9')`, `[9,0,1,2]`},
+		{`SELECT jsonb_insert('[0,1,2]', '{-1}', '9', true)`, `[0,1,2,9]`},
+		{`SELECT jsonb_insert('[0,1,2]', '{-2}', '9')`, `[0,9,1,2]`},
+		{`SELECT jsonb_insert('[0,1,2]', '{-2}', '9', true)`, `[0,1,9,2]`},
 		{`SELECT jsonb_set('{"a":1}', '{a}', NULL)`, nil},
 		{`SELECT jsonb_insert('[0,1]', '{-1}', '9')`, `[0,9,1]`},
-		// The JSON1 $ path form keeps its previous set/insert behavior and now
-		// returns canonical jsonb.
-		{`SELECT jsonb_set('{"a":1}', '$.b', '2')`, `{"a":1,"b":2}`},
-		{`SELECT jsonb_set('{"a":1}', '$.a', '2')`, `{"a":2}`},
-		{`SELECT jsonb_insert('{"a":1}', '$.b', '2')`, `{"a":1,"b":2}`},
+		// The JSON1 $ path form keeps its previous value semantics: a plain SQL
+		// string becomes a JSON string, while a jsonb-typed value is embedded.
+		{`SELECT jsonb_set('{"a":1}', '$.b', '2')`, `{"a":1,"b":"2"}`},
+		{`SELECT jsonb_set('{"a":1}', '$.a', '2')`, `{"a":"2"}`},
+		{`SELECT jsonb_set('{"a":1}', '$.b', '{"x":1}'::jsonb)`, `{"a":1,"b":{"x":1}}`},
+		{`SELECT jsonb_insert('{"a":1}', '$.b', '2')`, `{"a":1,"b":"2"}`},
+		{`SELECT jsonb_replace('{"a":1}', '$.a', '2')`, `{"a":"2"}`},
 		{`SELECT jsonb_remove('{"a":1,"b":2}', '$.a')`, `{"b":2}`},
 	}
 	for _, tc := range cases {
@@ -375,8 +393,11 @@ func TestJSONBScalarFunctions(t *testing.T) {
 		{`SELECT jsonb_typeof('not json')`, pgerror.CodeInvalidText},
 		{`SELECT jsonb_build_object('a')`, pgerror.CodeInvalidParameter},
 		{`SELECT jsonb_build_object(NULL, 1)`, pgerror.CodeInvalidParameter},
-		{`SELECT jsonb_set('{"a":1}', '{a,0}', '2')`, pgerror.CodeInvalidParameter},
-		{`SELECT jsonb_insert('{"a":1}', '{a,0}', '2')`, pgerror.CodeInvalidParameter},
+		{`SELECT jsonb_set('[1,2]', '{a}', '2')`, pgerror.CodeInvalidText},
+		{`SELECT jsonb_insert('[1,2]', '{a}', '2')`, pgerror.CodeInvalidText},
+		{`SELECT jsonb_insert('{"a":1}', '{a}', '2')`, pgerror.CodeInvalidParameter},
+		{`SELECT jsonb_set('1', '{a}', '2')`, pgerror.CodeInvalidParameter},
+		{`SELECT jsonb_insert('1', '{a}', '2')`, pgerror.CodeInvalidParameter},
 	} {
 		if _, err := execSQL(e, tc.sql); jsonbErrorCode(t, err) != tc.code {
 			t.Errorf("%s error = %v, want %s", tc.sql, err, tc.code)
@@ -622,3 +643,265 @@ func TestJSONBRejectsUnboundedCanonicalExpansion(t *testing.T) {
 		t.Fatalf("unbounded number error = %v", err)
 	}
 }
+
+// A PostgreSQL path element is resolved against the container it reaches:
+// numeric-looking text is an object key in an object and an integer index in an
+// array. This must hold for #>, #>>, #-, jsonb_set, and jsonb_insert.
+func TestJSONBNumericLookingObjectKeys(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+
+	cases := []struct {
+		sql  string
+		want interface{}
+	}{
+		{`SELECT '{"1":{"2":3}}'::jsonb #> '{1,2}'`, `3`},
+		{`SELECT '{"1":{"2":3}}'::jsonb #>> '{1,2}'`, `3`},
+		{`SELECT '{"1":{"2":3}}'::jsonb #- '{1,2}'`, `{"1":{}}`},
+		{`SELECT jsonb_set('{"1":{"2":3}}', '{1,2}', '4')`, `{"1":{"2":4}}`},
+		{`SELECT jsonb_insert('{"1":{"2":3}}', '{1,3}', '4')`, `{"1":{"2":3,"3":4}}`},
+		{`SELECT '{"01":"x"}'::jsonb #> '{01}'`, `"x"`},
+		{`SELECT '{"-1":"x"}'::jsonb #> '{-1}'`, `"x"`},
+		{`SELECT '{"1.5":"x"}'::jsonb #> '{1.5}'`, `"x"`},
+		{`SELECT jsonb_set('{"01":"x"}', '{01}', '"y"')`, `{"01":"y"}`},
+		{`SELECT jsonb_insert('{"01":"x"}', '{02}', '"y"')`, `{"01":"x","02":"y"}`},
+		{`SELECT jsonb_set('{"-1":"x"}', '{-1}', '"y"')`, `{"-1":"y"}`},
+		{`SELECT jsonb_insert('{"-1":"x"}', '{-2}', '"y"')`, `{"-1":"x","-2":"y"}`},
+		{`SELECT '{"1.5":"x"}'::jsonb #- '{1.5}'`, `{}`},
+		{`SELECT jsonb_set('{"1.5":"x"}', '{1.5}', '"y"')`, `{"1.5":"y"}`},
+		{`SELECT '[10,20]'::jsonb #> '{01}'`, `20`},
+		{`SELECT '[10,20]'::jsonb #> '{-1}'`, `20`},
+		{`SELECT '[10,20]'::jsonb #> '{a}'`, nil},
+	}
+	for _, tc := range cases {
+		if got := jsonbValue(t, e, tc.sql); got != tc.want {
+			t.Errorf("%s = %v (%T), want %v", tc.sql, got, got, tc.want)
+		}
+	}
+
+	// A non-integer path element on an array is an error for #-, matching
+	// PostgreSQL's path mutation and delete behavior.
+	if _, err := execSQL(e, `SELECT '[10,20]'::jsonb #- '{a}'`); jsonbErrorCode(t, err) != pgerror.CodeInvalidText {
+		t.Errorf("#- array non-integer element error = %v, want 22P02", err)
+	}
+}
+
+// #- must produce jsonb metadata and keep jsonb semantics for chained
+// operators, not fall back to text.
+func TestJSONBPathDeleteKeepsJSONBType(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+
+	res := execMust(t, e, `SELECT '{"t":true}'::jsonb #- '{x}'`)
+	if len(res.ColumnTypes) != 1 || res.ColumnTypes[0] != "JSONB" {
+		t.Fatalf("#- column type = %v, want JSONB", res.ColumnTypes)
+	}
+	if got := jsonbValue(t, e, `SELECT ('{"t":true}'::jsonb #- '{x}') ->> 't'`); got != "true" {
+		t.Fatalf("#- ->> = %v (%T), want true text", got, got)
+	}
+	if got := jsonbValue(t, e, `SELECT ('{"t":true}'::jsonb #- '{x}') = '{"t":true}'::jsonb`); got != true {
+		t.Fatalf("#- jsonb equality = %v", got)
+	}
+	if got := jsonbValue(t, e, `SELECT ('{"t":true}'::jsonb #- '{x}') ? 't'`); got != true {
+		t.Fatalf("#- ? = %v", got)
+	}
+}
+
+// jsonb equality and ordering use exact canonical bytes, not float64. DISTINCT,
+// GROUP BY, and containment must agree with =.
+func TestJSONBExactEqualityAndAggregateAgreement(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+
+	for _, sql := range []string{
+		`SELECT '1'::jsonb = '1.0000000000000000001'::jsonb`,
+		`SELECT '9007199254740993'::jsonb = '9007199254740992'::jsonb`,
+	} {
+		if got := jsonbValue(t, e, sql); got != false {
+			t.Errorf("%s = %v, want false", sql, got)
+		}
+	}
+	if got := jsonbValue(t, e, `SELECT '1'::jsonb = '1.0'::jsonb`); got != true {
+		t.Fatalf("1 = 1.0 = %v, want true", got)
+	}
+	if got := jsonbValue(t, e, `SELECT '1'::jsonb < '1.0000000000000000001'::jsonb`); got != true {
+		t.Fatalf("1 < 1.0000000000000000001 = %v, want true", got)
+	}
+	if got := jsonbValue(t, e, `SELECT '1'::jsonb IS DISTINCT FROM '1.0000000000000000001'::jsonb`); got != true {
+		t.Fatalf("1 IS DISTINCT FROM 1.0000000000000000001 = %v, want true", got)
+	}
+	if got := jsonbValue(t, e, `SELECT CASE '1'::jsonb WHEN '1.0000000000000000001'::jsonb THEN 'same' ELSE 'different' END`); got != "different" {
+		t.Fatalf("simple CASE exact jsonb equality = %v, want different", got)
+	}
+	if got := jsonbValue(t, e, `SELECT '1'::jsonb IN ('1.0'::jsonb, '1.0000000000000000001'::jsonb)`); got != true {
+		t.Fatalf("jsonb IN = %v, want true", got)
+	}
+	if got := jsonbValue(t, e, `SELECT '{"n":1}'::jsonb @> '{"n":1.0000000000000000001}'::jsonb`); got != false {
+		t.Fatalf("containment rounded a number: %v, want false", got)
+	}
+	if got := jsonbValue(t, e, `SELECT '{"n":1}'::jsonb @> '{"n":1.0}'::jsonb`); got != true {
+		t.Fatalf("containment of equal canonical numbers = %v, want true", got)
+	}
+
+	execMust(t, e, "CREATE TABLE exact (id INTEGER PRIMARY KEY, doc JSONB)")
+	execMust(t, e, `INSERT INTO exact VALUES (1, '1'), (2, '1.0'), (3, '1.0000000000000000001'), (4, '9007199254740993'), (5, '9007199254740992')`)
+	if res := execMust(t, e, `SELECT DISTINCT doc FROM exact`); res.RowCount != 4 {
+		t.Fatalf("DISTINCT exact jsonb rows = %v, want 4", res.Rows)
+	}
+	res := execMust(t, e, `SELECT doc, COUNT(*) FROM exact GROUP BY doc ORDER BY doc`)
+	if res.RowCount != 4 {
+		t.Fatalf("GROUP BY exact jsonb rows = %v, want 4", res.Rows)
+	}
+	if res.Rows[0][1] != int64(2) {
+		t.Fatalf("GROUP BY merged canonical numbers = %v, want two spellings of 1", res.Rows[0])
+	}
+	res = execMust(t, e, `SELECT COUNT(*) FROM exact WHERE doc = '1'::jsonb`)
+	if res.Rows[0][0] != int64(2) {
+		t.Fatalf("equality count = %v, want 2", res.Rows[0][0])
+	}
+	res = execMust(t, e, `SELECT COUNT(*) FROM exact WHERE doc @> '1'::jsonb`)
+	if res.Rows[0][0] != int64(2) {
+		t.Fatalf("containment count = %v, want 2", res.Rows[0][0])
+	}
+}
+
+// Containment tracks consumed array matches, as required by the review gate:
+// a candidate with duplicates needs a distinct container element per copy.
+// A jsonb extraction result must not be silently compared with a SQL number.
+func TestJSONBExtractionComparisonTypeError(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	for _, sql := range []string{
+		`SELECT '{"a":1}'::jsonb -> 'a' = 1`,
+		`SELECT '{"a":1}'::jsonb -> 'a' = TRUE`,
+	} {
+		if _, err := execSQL(e, sql); jsonbErrorCode(t, err) != pgerror.CodeDatatypeMismatch {
+			t.Errorf("%s error = %v, want 42804", sql, err)
+		}
+	}
+	if got := jsonbValue(t, e, `SELECT '{"a":1}'::jsonb -> 'a' = '1'::jsonb`); got != true {
+		t.Fatalf("jsonb -> = jsonb = %v, want true", got)
+	}
+}
+
+func TestJSONBArrayContainmentMultiplicity(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	cases := []struct {
+		sql  string
+		want interface{}
+	}{
+		{`SELECT '[1]'::jsonb @> '[1,1]'::jsonb`, false},
+		{`SELECT '[1,1]'::jsonb @> '[1]'::jsonb`, true},
+		{`SELECT '[1,2,2]'::jsonb @> '[2,2]'::jsonb`, true},
+		{`SELECT '[1,2]'::jsonb @> '[2,2]'::jsonb`, false},
+		{`SELECT '[1]'::jsonb @> '1'::jsonb`, true},
+	}
+	for _, tc := range cases {
+		if got := jsonbValue(t, e, tc.sql); got != tc.want {
+			t.Errorf("%s = %v, want %v", tc.sql, got, tc.want)
+		}
+	}
+}
+
+// A JSONB column written before native JSONB may hold non-canonical text. Reads
+// canonicalize it so projection, DISTINCT, GROUP BY, and ORDER BY all observe
+// the canonical value.
+func TestJSONBLegacyValuesCanonicalizeOnRead(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE legacy (id INTEGER PRIMARY KEY, doc JSONB)")
+
+	legacy := []string{
+		`{ "a" : 1 }`,
+		`{"a":1}`,
+		` { "a" :1 } `,
+		`{"b": 2,"a": 1}`,
+		`{"a":1,"b":2}`,
+		` { "b" : 2, "a" : 1 } `,
+	}
+	for i, raw := range legacy {
+		if err := table.Insert("legacy", storage.Row{"id": int64(i + 1), "doc": raw}); err != nil {
+			t.Fatalf("legacy insert %d: %v", i, err)
+		}
+	}
+	e = newExec(schema, table)
+
+	res := execMust(t, e, `SELECT doc FROM legacy ORDER BY id`)
+	if res.RowCount != len(legacy) {
+		t.Fatalf("legacy rows = %d", res.RowCount)
+	}
+	for _, row := range res.Rows {
+		if row[0] != `{"a":1}` && row[0] != `{"a":1,"b":2}` {
+			t.Fatalf("legacy read = %v, want canonical text", row[0])
+		}
+	}
+	res = execMust(t, e, `SELECT DISTINCT doc FROM legacy`)
+	if res.RowCount != 2 {
+		t.Fatalf("legacy DISTINCT rows = %v, want 2", res.Rows)
+	}
+	res = execMust(t, e, `SELECT DISTINCT * FROM legacy`)
+	if res.RowCount != len(legacy) {
+		t.Fatalf("legacy DISTINCT * rows = %d", res.RowCount)
+	}
+	res = execMust(t, e, `SELECT * FROM legacy ORDER BY id`)
+	for _, row := range res.Rows {
+		if row[1] != `{"a":1}` && row[1] != `{"a":1,"b":2}` {
+			t.Fatalf("legacy SELECT * read = %v, want canonical text", row[1])
+		}
+	}
+	if got := jsonbValue(t, e, `SELECT COUNT(DISTINCT doc) FROM legacy`); got != int64(2) {
+		t.Fatalf("legacy COUNT(DISTINCT doc) = %v, want 2", got)
+	}
+	res = execMust(t, e, `SELECT doc, COUNT(*) FROM legacy GROUP BY doc`)
+	if res.RowCount != 2 {
+		t.Fatalf("legacy GROUP BY rows = %v, want 2", res.Rows)
+	}
+	res = execMust(t, e, `SELECT id FROM legacy ORDER BY doc, id`)
+	if res.RowCount != len(legacy) {
+		t.Fatalf("legacy ORDER BY rows = %d", res.RowCount)
+	}
+	if got := jsonbValue(t, e, `SELECT doc FROM legacy WHERE id = 1`); got != `{"a":1}` {
+		t.Fatalf("legacy selected value = %v, want canonical", got)
+	}
+}
+
+// Escapes that encoding/json would silently rewrite are invalid jsonb input.
+func TestJSONBRejectsNULAndLoneSurrogates(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+
+	for _, sql := range []string{
+		`SELECT '{"a":"\u0000"}'::jsonb`,
+		`SELECT '"\ud800"'::jsonb`,
+		`SELECT '"\ud800x"'::jsonb`,
+		`SELECT '"\udc00"'::jsonb`,
+		`SELECT '{"\u0000":1}'::jsonb`,
+	} {
+		if _, err := execSQL(e, sql); jsonbErrorCode(t, err) != pgerror.CodeInvalidText {
+			t.Errorf("%s error = %v, want 22P02", sql, err)
+		}
+	}
+	if got := jsonbValue(t, e, `SELECT '"\u0041"'::jsonb`); got != `"A"` {
+		t.Fatalf("escaped ASCII = %v", got)
+	}
+	if got := jsonbValue(t, e, `SELECT '"\ud83d\ude00"'::jsonb`); got != "\"\U0001F600\"" {
+		t.Fatalf("surrogate pair = %v", got)
+	}
+}
+
+// Zero-row text set-returning functions must still report TEXT for the value
+// column.
+func TestJSONBTextSetReturningTypesOnNull(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+
+	res := execMust(t, e, `SELECT * FROM jsonb_each_text(NULL)`)
+	if res.RowCount != 0 || len(res.ColumnTypes) != 2 || res.ColumnTypes[0] != "TEXT" || res.ColumnTypes[1] != "TEXT" {
+		t.Fatalf("jsonb_each_text(NULL) = rows %d types %v, want 0 [TEXT TEXT]", res.RowCount, res.ColumnTypes)
+	}
+	res = execMust(t, e, `SELECT * FROM jsonb_array_elements_text(NULL)`)
+	if res.RowCount != 0 || len(res.ColumnTypes) != 1 || res.ColumnTypes[0] != "TEXT" {
+		t.Fatalf("jsonb_array_elements_text(NULL) = rows %d types %v, want 0 [TEXT]", res.RowCount, res.ColumnTypes)
+	}
+}

+ 3 - 3
pkg/executor/result_types.go

@@ -519,9 +519,9 @@ func binaryResultType(e *parser.BinaryExpr, resolve columnTypeResolver) string {
 			return "JSONB"
 		}
 		return "TEXT"
-	case lexer.TokenArrow, lexer.TokenHashArrow:
-		// -> and #> keep the jsonb type when the document is jsonb; JSON1 text
-		// extraction reports text.
+	case lexer.TokenArrow, lexer.TokenHashArrow, lexer.TokenHashMinus:
+		// ->, #>, and #- keep the jsonb type when the document is jsonb; JSON1
+		// text extraction reports text.
 		if strings.EqualFold(inferExprType(e.Left, resolve), "JSONB") {
 			return "JSONB"
 		}

+ 10 - 2
pkg/executor/returning.go

@@ -68,11 +68,19 @@ func (e *Executor) returningResult(cols []parser.SelectColumn, schema *storage.S
 			switch {
 			case col.Star:
 				for _, c := range schema.Columns {
-					values = append(values, e.lookupRowColumn(row, c.Name))
+					canonical, err := canonicalizeJSONBReadValue(c.Type, e.lookupRowColumn(row, c.Name))
+					if err != nil {
+						return nil, err
+					}
+					values = append(values, canonical)
 				}
 			case col.TableStar != "":
 				for _, c := range schema.Columns {
-					values = append(values, e.lookupRowColumn(row, c.Name))
+					canonical, err := canonicalizeJSONBReadValue(c.Type, e.lookupRowColumn(row, c.Name))
+					if err != nil {
+						return nil, err
+					}
+					values = append(values, canonical)
 				}
 			default:
 				val, err := e.evalExpr(col.Expr, row)

+ 5 - 1
pkg/executor/sqlite_catalog.go

@@ -165,7 +165,11 @@ func (e *Executor) executeSelectOnRows(stmt *parser.SelectStmt, rows []storage.R
 		for _, col := range stmt.Columns {
 			if col.Star {
 				for _, c := range schema.Columns {
-					values = append(values, row[c.Name])
+					canonical, err := canonicalizeJSONBReadValue(c.Type, row[c.Name])
+					if err != nil {
+						return nil, err
+					}
+					values = append(values, canonical)
 				}
 			} else {
 				val, err := e.evalExpr(col.Expr, row)

+ 13 - 1
pkg/pgserver/connection.go

@@ -1186,7 +1186,19 @@ func parameterLiteral(param boundParameter) (string, error) {
 			return "", fmt.Errorf("%w: binary array parameter format for OID %d; send arrays in text format", pgerror.ErrFeatureNotSupported, param.oid)
 		}
 		if param.oid == 3802 {
-			return "", fmt.Errorf("%w: binary jsonb parameter format for OID 3802; send jsonb in text format", pgerror.ErrFeatureNotSupported)
+			// PostgreSQL's binary jsonb format is a version byte followed by the
+			// document text. pgx sends this form by default.
+			if len(param.value) == 0 {
+				return "", pgerror.InvalidText("jsonb binary parameter is empty")
+			}
+			if param.value[0] != 1 {
+				return "", pgerror.InvalidText("unsupported jsonb binary format version %d", param.value[0])
+			}
+			canonical, err := executor.CanonicalizeJSONB(string(param.value[1:]))
+			if err != nil {
+				return "", err
+			}
+			return "'" + strings.ReplaceAll(canonical, "'", "''") + "'::jsonb", nil
 		}
 		switch param.oid {
 		case 16:

+ 31 - 5
pkg/pgserver/jsonb_contract_test.go

@@ -93,12 +93,38 @@ func TestJSONBTextParameterOverExtendedQuery(t *testing.T) {
 		t.Fatalf("jsonb INSERT round trip = %v", row)
 	}
 
-	// Binary jsonb is rejected instead of being misread as UTF-8 JSON.
+	// PostgreSQL binary jsonb is a version byte plus the document text. pgx sends
+	// this form by default, so it is accepted and canonicalized.
 	conn.reset()
-	messages = runExtendedJSONBBind(t, c, conn, `SELECT $1`, 3802, 1, []byte{0x01, 0x02})
-	errMsg = findError(messages)
-	if errMsg == nil || errorCode(errMsg) != ErrCodeFeatureNotSupported {
-		t.Fatalf("binary jsonb parameter error = %v, want 0A000", messages)
+	messages = runExtendedJSONBBind(t, c, conn, `SELECT $1 -> 'a'`, 3802, 1, append([]byte{0x01}, []byte(`{ "a" : 1.50 }`)...))
+	if errMsg := findError(messages); errMsg != nil {
+		t.Fatalf("binary jsonb parameter failed: %s", errorCode(errMsg))
+	}
+	if row := firstDataRow(t, messages); len(row) != 1 || row[0] != "1.5" {
+		t.Fatalf("binary jsonb parameter = %v, want [1.5]", row)
+	}
+	conn.reset()
+	messages = runExtendedJSONBBind(t, c, conn, `SELECT $1`, 3802, 1, append([]byte{0x01}, []byte(`[1, 2]`)...))
+	if row := firstDataRow(t, messages); len(row) != 1 || row[0] != `[1,2]` {
+		t.Fatalf("binary jsonb canonicalization = %v, want [1,2]", row)
+	}
+
+	// A wrong version and a malformed body are invalid text (22P02), not 0A000.
+	for _, tc := range []struct {
+		value []byte
+		code  string
+	}{
+		{[]byte{0x02, '{', '}'}, ErrCodeInvalidText},
+		{[]byte{0x01}, ErrCodeInvalidText},
+		{[]byte{0x01, 'n', 'o', 't'}, ErrCodeInvalidText},
+		{[]byte{}, ErrCodeInvalidText},
+	} {
+		conn.reset()
+		messages = runExtendedJSONBBind(t, c, conn, `SELECT $1`, 3802, 1, tc.value)
+		errMsg := findError(messages)
+		if errMsg == nil || errorCode(errMsg) != tc.code {
+			t.Fatalf("binary jsonb %x error = %v, want %s", tc.value, messages, tc.code)
+		}
 	}
 }