Explorar el Código

Merge pull request #39 from database-pizza/engine/c-jsonb

add native JSONB semantics
Danilo Fragoso hace 1 día
padre
commit
6440f539a1

+ 2 - 2
README.md

@@ -320,9 +320,9 @@ pgcli postgresql://localhost:5432/pizzasql
 1. **SQL Dialect**: PizzaSQL uses **SQLite syntax**, not PostgreSQL syntax
    - 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. Initialization probes such as `SELECT version()` and `SHOW server_version` match complete token sequences, never substrings inside SQL literals or unrelated projections.
+2. **Parameter placeholders**: use `$1, $2...` (PostgreSQL style) or `?` (SQLite style). PG Bind rewrites `$n` only outside quoted regions and line/block comments. The HTTP `/query` API substitutes `?` textually, so a statement that uses the jsonb `?` key-existence operator cannot also use `?` positional parameters; use the PostgreSQL protocol or `$n`-free SQL there. Initialization probes such as `SELECT version()` and `SHOW server_version` match complete token sequences, never substrings inside SQL literals or unrelated projections.
 3. **Extended protocol scope**: Parse accepts one command; Bind requests must use text parameters/all-text results. `Describe` returns `ParameterDescription` then `NoData` without executing anything; `Execute` still sends the real `RowDescription`. Binary results and nonzero Execute row limits fail with SQLSTATE `0A000` before side effects. Portals are one-shot, not resumable. Unsupported binary parameter OIDs also report `0A000`.
-4. **No PostgreSQL-specific features**: no schemas, roles, `ARRAY`, `JSONB`, etc.
+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.
 
 ---
 

+ 111 - 15
pkg/analyzer/analyzer.go

@@ -300,17 +300,10 @@ func (a *Analyzer) defineTableRef(ref parser.TableRef) error {
 				return err
 			}
 		}
-		// unnest() produces one column with the function's name, matching
-		// PostgreSQL's output column name.
 		a.scope.DefineTable(&TableInfo{
-			Name:  ref.Name,
-			Alias: ref.Alias,
-			Columns: []ColumnInfo{{
-				Name:      strings.ToLower(ref.Function.Name),
-				Type:      TypeAny,
-				Nullable:  true,
-				TableName: ref.Name,
-			}},
+			Name:    ref.Name,
+			Alias:   ref.Alias,
+			Columns: tableFunctionColumns(ref.Function.Name),
 		})
 		return nil
 	}
@@ -1068,7 +1061,28 @@ func (a *Analyzer) analyzeBinaryExpr(e *parser.BinaryExpr) (*ExprInfo, error) {
 	}
 
 	switch e.Op {
-	case lexer.TokenPlus, lexer.TokenMinus, lexer.TokenStar, lexer.TokenSlash, lexer.TokenPercent:
+	case lexer.TokenMinus:
+		// `jsonb - text`, `jsonb - integer`, and `jsonb - text[]` delete a key or
+		// array element instead of subtracting. A jsonb right operand with any
+		// other arithmetic operator stays a type error.
+		if left.Type == TypeJSONB {
+			if right.Type == TypeJSONB {
+				return nil, &AnalysisError{
+					Type:    ErrTypeMismatch,
+					Message: "jsonb deletion requires a text key, integer index, or text[] path, not a jsonb operand",
+				}
+			}
+			info.Type = TypeJSONB
+			return info, nil
+		}
+		if right.Type == TypeJSONB {
+			return nil, &AnalysisError{
+				Type:    ErrTypeMismatch,
+				Message: "cannot subtract JSONB from a non-jsonb value",
+			}
+		}
+		fallthrough
+	case lexer.TokenPlus, lexer.TokenStar, lexer.TokenSlash, lexer.TokenPercent:
 		// Arithmetic operators. TypeAny comes from derived tables and CTEs whose
 		// column types are not tracked, so it is allowed rather than rejected.
 		info.Type = CommonType(left.Type, right.Type)
@@ -1105,12 +1119,57 @@ func (a *Analyzer) analyzeBinaryExpr(e *parser.BinaryExpr) (*ExprInfo, error) {
 		// Logical operators
 		info.Type = TypeBoolean
 	case lexer.TokenConcat:
-		// String concatenation
+		// String concatenation, or jsonb merge when either side is jsonb.
+		if left.Type == TypeJSONB || right.Type == TypeJSONB {
+			other := left.Type
+			if left.Type == TypeJSONB {
+				other = right.Type
+			}
+			if !jsonbTextOperand(other) {
+				return nil, &AnalysisError{
+					Type:    ErrTypeMismatch,
+					Message: fmt.Sprintf("jsonb concatenation requires a jsonb or text operand, got %s", other),
+				}
+			}
+			info.Type = TypeJSONB
+			return info, nil
+		}
+		info.Type = TypeText
+	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.
+		info.Type = TypeAny
+		if left.Type == TypeJSONB {
+			info.Type = TypeJSONB
+		}
+	case lexer.TokenHashArrowText:
 		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.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
 	}
@@ -1142,6 +1201,17 @@ func arrayLiteralComparison(left, right *ExprInfo) bool {
 	return (left.Array && textCoercible(right)) || (right.Array && textCoercible(left))
 }
 
+// jsonbTextOperand reports whether a type can stand on the other side of a
+// jsonb operator: a jsonb value or text that is read as a jsonb document.
+func jsonbTextOperand(t Type) bool {
+	switch t {
+	case TypeJSONB, TypeText, TypeBlob, TypeNull, TypeUnknown, TypeAny:
+		return true
+	default:
+		return false
+	}
+}
+
 func (a *Analyzer) analyzeUnaryExpr(e *parser.UnaryExpr) (*ExprInfo, error) {
 	operand, err := a.analyzeExpr(e.Operand)
 	if err != nil {
@@ -1177,6 +1247,32 @@ func (a *Analyzer) analyzeUnaryExpr(e *parser.UnaryExpr) (*ExprInfo, error) {
 	return info, nil
 }
 
+// tableFunctionColumns reports the output columns of a set-returning function
+// used as the single FROM item, using PostgreSQL's column names.
+func tableFunctionColumns(name string) []ColumnInfo {
+	switch strings.ToLower(name) {
+	case "jsonb_each", "jsonb_each_text":
+		valueType := TypeJSONB
+		if strings.EqualFold(name, "jsonb_each_text") {
+			valueType = TypeText
+		}
+		return []ColumnInfo{
+			{Name: "key", Type: TypeText, Nullable: true, TableName: name},
+			{Name: "value", Type: valueType, Nullable: true, TableName: name},
+		}
+	case "jsonb_array_elements":
+		return []ColumnInfo{{Name: "value", Type: TypeJSONB, Nullable: true, TableName: name}}
+	case "jsonb_array_elements_text":
+		return []ColumnInfo{{Name: "value", Type: TypeText, Nullable: true, TableName: name}}
+	case "jsonb_object_keys":
+		return []ColumnInfo{{Name: "jsonb_object_keys", Type: TypeText, Nullable: true, TableName: name}}
+	default:
+		// unnest() produces one column with the function's name, matching
+		// PostgreSQL's output column name.
+		return []ColumnInfo{{Name: strings.ToLower(name), Type: TypeAny, Nullable: true, TableName: name}}
+	}
+}
+
 func (a *Analyzer) analyzeFunctionCall(e *parser.FunctionCall) (*ExprInfo, error) {
 	sig, ok := LookupFunction(e.Name)
 	if !ok {

+ 82 - 0
pkg/analyzer/jsonb_test.go

@@ -0,0 +1,82 @@
+package analyzer
+
+import (
+	"testing"
+)
+
+// jsonbCatalog adds a table with a JSONB column to the shared test catalog.
+func jsonbCatalog() *Catalog {
+	catalog := setupCatalog()
+	catalog.CreateTable(&TableInfo{
+		Name: "docs",
+		Columns: []ColumnInfo{
+			{Name: "id", Type: TypeInteger, PrimaryKey: true},
+			{Name: "doc", Type: TypeJSONB, Nullable: true},
+			{Name: "body", Type: TypeText, Nullable: true},
+		},
+	})
+	return catalog
+}
+
+func TestTypeFromNameJSONB(t *testing.T) {
+	if got := TypeFromName("JSONB"); got != TypeJSONB {
+		t.Fatalf("TypeFromName(JSONB) = %v, want JSONB", got)
+	}
+	// JSON stays a text-backed alias and must not be promoted to native jsonb.
+	if got := TypeFromName("JSON"); got == TypeJSONB {
+		t.Fatal("TypeFromName(JSON) = JSONB, want the existing text-backed affinity")
+	}
+	if !TypeJSONB.IsComparable(TypeJSONB) || !TypeJSONB.IsComparable(TypeText) {
+		t.Fatal("jsonb must compare with jsonb and text")
+	}
+	if TypeJSONB.IsComparable(TypeInteger) {
+		t.Fatal("jsonb must not compare with an integer")
+	}
+	if got := CommonType(TypeJSONB, TypeText); got != TypeJSONB {
+		t.Fatalf("CommonType(JSONB, TEXT) = %v, want JSONB", got)
+	}
+}
+
+func TestAnalyzeJSONBExpressions(t *testing.T) {
+	a := New(jsonbCatalog())
+	for _, sql := range []string{
+		`SELECT doc -> 'a', doc ->> 'a' FROM docs`,
+		`SELECT doc #> '{a}', doc #>> '{a}', doc #- '{a}' FROM docs`,
+		`SELECT doc @> '{}'::jsonb, doc <@ '{}'::jsonb FROM docs`,
+		`SELECT doc ? 'a', doc ?| '{a}', doc ?& '{a}' FROM docs`,
+		`SELECT doc || '{}'::jsonb, doc - 'a', doc - 1 FROM docs`,
+		`SELECT '{}'::jsonb, CAST('{}' AS JSONB), to_jsonb(doc) FROM docs`,
+		`SELECT jsonb_typeof(doc), jsonb_array_length(doc), jsonb_pretty(doc) FROM docs`,
+		`SELECT jsonb_build_object('a', 1), jsonb_build_array(1, 'x') FROM docs`,
+		`SELECT jsonb_set(doc, '{a}', '1'), jsonb_insert(doc, '{a}', '1') FROM docs`,
+		`SELECT * FROM jsonb_each('{"a":1}')`,
+		`SELECT value FROM jsonb_array_elements('[1]')`,
+		`SELECT jsonb_object_keys FROM jsonb_object_keys('{"a":1}')`,
+		`SELECT CASE WHEN id = 1 THEN doc ELSE '{}'::jsonb END FROM docs`,
+		`SELECT COALESCE(doc, '{}'::jsonb) FROM docs`,
+	} {
+		if err := analyze(t, a, sql); err != nil {
+			t.Errorf("%s: unexpected error: %v", sql, err)
+		}
+	}
+
+	for _, sql := range []string{
+		`SELECT doc = 5 FROM docs`,
+		`SELECT doc + 1 FROM docs`,
+		`SELECT 1 - doc FROM docs`,
+		`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 {
+			t.Errorf("%s: expected a type error", sql)
+			continue
+		}
+		if analysisErr, ok := err.(*AnalysisError); !ok || analysisErr.Type != ErrTypeMismatch {
+			t.Errorf("%s: error = %v, want a type mismatch", sql, err)
+		}
+	}
+}

+ 60 - 4
pkg/analyzer/types.go

@@ -18,6 +18,7 @@ const (
 	TypeNumeric             // NUMERIC affinity (flexible)
 	TypeBoolean             // Boolean (stored as INTEGER in SQLite)
 	TypeAny                 // Any type (for polymorphic functions)
+	TypeJSONB               // Native JSONB (canonical JSON text, compared as jsonb)
 )
 
 func (t Type) String() string {
@@ -40,6 +41,8 @@ func (t Type) String() string {
 		return "BOOLEAN"
 	case TypeAny:
 		return "ANY"
+	case TypeJSONB:
+		return "JSONB"
 	default:
 		return "UNKNOWN"
 	}
@@ -53,6 +56,12 @@ func TypeFromName(name string) Type {
 	// distinct array type; element storage stays a canonical text value.
 	upper = strings.TrimSuffix(upper, "[]")
 
+	// JSONB is a native type: values are canonical JSON text but compare, order,
+	// and report as jsonb rather than as text or numeric affinity.
+	if upper == "JSONB" {
+		return TypeJSONB
+	}
+
 	// Rule 1: If the type contains "INT" -> INTEGER
 	if strings.Contains(upper, "INT") {
 		return TypeInteger
@@ -124,6 +133,13 @@ func (t Type) IsComparable(other Type) bool {
 	if (t == TypeText || t == TypeBlob) && (other == TypeText || other == TypeBlob) {
 		return true
 	}
+	// JSONB compares with jsonb or with text that is read as a jsonb document.
+	if t == TypeJSONB || other == TypeJSONB {
+		textLike := func(x Type) bool {
+			return x == TypeJSONB || x == TypeText || x == TypeBlob || x == TypeNull || x == TypeAny
+		}
+		return textLike(t) && textLike(other)
+	}
 	// NUMERIC/BOOLEAN accepts TEXT (SQLite-compatible: dates stored as text in numeric columns)
 	if (t == TypeNumeric || t == TypeBoolean) && (other == TypeText || other == TypeBlob) {
 		return true
@@ -174,6 +190,15 @@ func CommonType(a, b Type) Type {
 		return TypeText
 	}
 
+	// JSONB wins over text: a CASE or COALESCE branch that produces jsonb keeps
+	// its native semantics.
+	if a == TypeJSONB && (b == TypeText || b == TypeBlob) {
+		return TypeJSONB
+	}
+	if b == TypeJSONB && (a == TypeText || a == TypeBlob) {
+		return TypeJSONB
+	}
+
 	return TypeText // Default to TEXT for mixed types
 }
 
@@ -270,13 +295,13 @@ var builtinFunctions = map[string]FunctionSignature{
 	"JSON_EXTRACT":      {Name: "JSON_EXTRACT", MinArgs: 2, MaxArgs: -1, ArgTypes: []Type{TypeAny, TypeText}, ReturnType: TypeAny, IsAggregate: false},
 	"JSONB_EXTRACT":     {Name: "JSONB_EXTRACT", MinArgs: 2, MaxArgs: -1, ArgTypes: []Type{TypeAny, TypeText}, ReturnType: TypeAny, IsAggregate: false},
 	"JSON_SET":          {Name: "JSON_SET", MinArgs: 3, MaxArgs: -1, ArgTypes: []Type{TypeAny, TypeText, TypeAny}, ReturnType: TypeText, IsAggregate: false},
-	"JSONB_SET":         {Name: "JSONB_SET", MinArgs: 3, MaxArgs: -1, ArgTypes: []Type{TypeAny, TypeText, TypeAny}, ReturnType: TypeText, IsAggregate: false},
+	"JSONB_SET":         {Name: "JSONB_SET", MinArgs: 3, MaxArgs: -1, ArgTypes: []Type{TypeAny, TypeText, TypeAny}, ReturnType: TypeJSONB, IsAggregate: false},
 	"JSON_INSERT":       {Name: "JSON_INSERT", MinArgs: 3, MaxArgs: -1, ArgTypes: []Type{TypeAny, TypeText, TypeAny}, ReturnType: TypeText, IsAggregate: false},
-	"JSONB_INSERT":      {Name: "JSONB_INSERT", MinArgs: 3, MaxArgs: -1, ArgTypes: []Type{TypeAny, TypeText, TypeAny}, ReturnType: TypeText, IsAggregate: false},
+	"JSONB_INSERT":      {Name: "JSONB_INSERT", MinArgs: 3, MaxArgs: -1, ArgTypes: []Type{TypeAny, TypeText, TypeAny}, ReturnType: TypeJSONB, IsAggregate: false},
 	"JSON_REPLACE":      {Name: "JSON_REPLACE", MinArgs: 3, MaxArgs: -1, ArgTypes: []Type{TypeAny, TypeText, TypeAny}, ReturnType: TypeText, IsAggregate: false},
-	"JSONB_REPLACE":     {Name: "JSONB_REPLACE", MinArgs: 3, MaxArgs: -1, ArgTypes: []Type{TypeAny, TypeText, TypeAny}, ReturnType: TypeText, IsAggregate: false},
+	"JSONB_REPLACE":     {Name: "JSONB_REPLACE", MinArgs: 3, MaxArgs: -1, ArgTypes: []Type{TypeAny, TypeText, TypeAny}, ReturnType: TypeJSONB, IsAggregate: false},
 	"JSON_REMOVE":       {Name: "JSON_REMOVE", MinArgs: 2, MaxArgs: -1, ArgTypes: []Type{TypeAny, TypeText}, ReturnType: TypeText, IsAggregate: false},
-	"JSONB_REMOVE":      {Name: "JSONB_REMOVE", MinArgs: 2, MaxArgs: -1, ArgTypes: []Type{TypeAny, TypeText}, ReturnType: TypeText, IsAggregate: false},
+	"JSONB_REMOVE":      {Name: "JSONB_REMOVE", MinArgs: 2, MaxArgs: -1, ArgTypes: []Type{TypeAny, TypeText}, ReturnType: TypeJSONB, IsAggregate: false},
 	"JSON_ARRAY":        {Name: "JSON_ARRAY", MinArgs: 0, MaxArgs: -1, ArgTypes: []Type{TypeAny}, ReturnType: TypeText, IsAggregate: false},
 	"JSON_ARRAY_LENGTH": {Name: "JSON_ARRAY_LENGTH", MinArgs: 1, MaxArgs: 2, ArgTypes: []Type{TypeAny, TypeText}, ReturnType: TypeInteger, IsAggregate: false},
 	"JSONB_ARRAY":       {Name: "JSONB_ARRAY", MinArgs: 0, MaxArgs: -1, ArgTypes: []Type{TypeAny}, ReturnType: TypeText, IsAggregate: false},
@@ -288,6 +313,37 @@ var builtinFunctions = map[string]FunctionSignature{
 	// JSON1 aggregate.
 	"JSON_GROUP_ARRAY":  {Name: "JSON_GROUP_ARRAY", MinArgs: 1, MaxArgs: 1, ArgTypes: []Type{TypeAny}, ReturnType: TypeText, IsAggregate: true},
 	"JSONB_GROUP_ARRAY": {Name: "JSONB_GROUP_ARRAY", MinArgs: 1, MaxArgs: 1, ArgTypes: []Type{TypeAny}, ReturnType: TypeText, IsAggregate: true},
+
+	// Native JSONB scalar functions.
+	"TO_JSONB":           {Name: "TO_JSONB", MinArgs: 1, MaxArgs: 1, ArgTypes: []Type{TypeAny}, ReturnType: TypeJSONB, IsAggregate: false},
+	"JSONB_TYPEOF":       {Name: "JSONB_TYPEOF", MinArgs: 1, MaxArgs: 1, ArgTypes: []Type{TypeAny}, ReturnType: TypeText, IsAggregate: false},
+	"JSONB_ARRAY_LENGTH": {Name: "JSONB_ARRAY_LENGTH", MinArgs: 1, MaxArgs: 1, ArgTypes: []Type{TypeAny}, ReturnType: TypeInteger, IsAggregate: false},
+	"JSONB_STRIP_NULLS":  {Name: "JSONB_STRIP_NULLS", MinArgs: 1, MaxArgs: 1, ArgTypes: []Type{TypeAny}, ReturnType: TypeJSONB, IsAggregate: false},
+	"JSONB_PRETTY":       {Name: "JSONB_PRETTY", MinArgs: 1, MaxArgs: 1, ArgTypes: []Type{TypeAny}, ReturnType: TypeText, IsAggregate: false},
+	"JSONB_BUILD_OBJECT": {Name: "JSONB_BUILD_OBJECT", MinArgs: 0, MaxArgs: -1, ArgTypes: []Type{TypeAny}, ReturnType: TypeJSONB, IsAggregate: false},
+	"JSONB_BUILD_ARRAY":  {Name: "JSONB_BUILD_ARRAY", MinArgs: 0, MaxArgs: -1, ArgTypes: []Type{TypeAny}, ReturnType: TypeJSONB, IsAggregate: false},
+
+	// Native JSONB set-returning functions. They are only executable as the
+	// single FROM table function; a projection call is rejected with 0A000.
+	"JSONB_EACH":                {Name: "JSONB_EACH", MinArgs: 1, MaxArgs: 1, ArgTypes: []Type{TypeAny}, ReturnType: TypeAny, IsAggregate: false},
+	"JSONB_EACH_TEXT":           {Name: "JSONB_EACH_TEXT", MinArgs: 1, MaxArgs: 1, ArgTypes: []Type{TypeAny}, ReturnType: TypeAny, IsAggregate: false},
+	"JSONB_ARRAY_ELEMENTS":      {Name: "JSONB_ARRAY_ELEMENTS", MinArgs: 1, MaxArgs: 1, ArgTypes: []Type{TypeAny}, ReturnType: TypeAny, IsAggregate: false},
+	"JSONB_ARRAY_ELEMENTS_TEXT": {Name: "JSONB_ARRAY_ELEMENTS_TEXT", MinArgs: 1, MaxArgs: 1, ArgTypes: []Type{TypeAny}, ReturnType: TypeAny, IsAggregate: false},
+	"JSONB_OBJECT_KEYS":         {Name: "JSONB_OBJECT_KEYS", MinArgs: 1, MaxArgs: 1, ArgTypes: []Type{TypeAny}, ReturnType: TypeText, IsAggregate: false},
+
+	// jsonpath is not implemented. The names are registered so the executor can
+	// reject them with SQLSTATE 0A000 instead of the analyzer reporting an
+	// unknown function.
+	"JSONB_PATH_EXISTS":         {Name: "JSONB_PATH_EXISTS", MinArgs: 2, MaxArgs: -1, ArgTypes: []Type{TypeAny}, ReturnType: TypeBoolean, IsAggregate: false},
+	"JSONB_PATH_EXISTS_TZ":      {Name: "JSONB_PATH_EXISTS_TZ", MinArgs: 2, MaxArgs: -1, ArgTypes: []Type{TypeAny}, ReturnType: TypeBoolean, IsAggregate: false},
+	"JSONB_PATH_MATCH":          {Name: "JSONB_PATH_MATCH", MinArgs: 2, MaxArgs: -1, ArgTypes: []Type{TypeAny}, ReturnType: TypeBoolean, IsAggregate: false},
+	"JSONB_PATH_MATCH_TZ":       {Name: "JSONB_PATH_MATCH_TZ", MinArgs: 2, MaxArgs: -1, ArgTypes: []Type{TypeAny}, ReturnType: TypeBoolean, IsAggregate: false},
+	"JSONB_PATH_QUERY":          {Name: "JSONB_PATH_QUERY", MinArgs: 2, MaxArgs: -1, ArgTypes: []Type{TypeAny}, ReturnType: TypeAny, IsAggregate: false},
+	"JSONB_PATH_QUERY_TZ":       {Name: "JSONB_PATH_QUERY_TZ", MinArgs: 2, MaxArgs: -1, ArgTypes: []Type{TypeAny}, ReturnType: TypeAny, IsAggregate: false},
+	"JSONB_PATH_QUERY_ARRAY":    {Name: "JSONB_PATH_QUERY_ARRAY", MinArgs: 2, MaxArgs: -1, ArgTypes: []Type{TypeAny}, ReturnType: TypeAny, IsAggregate: false},
+	"JSONB_PATH_QUERY_ARRAY_TZ": {Name: "JSONB_PATH_QUERY_ARRAY_TZ", MinArgs: 2, MaxArgs: -1, ArgTypes: []Type{TypeAny}, ReturnType: TypeAny, IsAggregate: false},
+	"JSONB_PATH_QUERY_FIRST":    {Name: "JSONB_PATH_QUERY_FIRST", MinArgs: 2, MaxArgs: -1, ArgTypes: []Type{TypeAny}, ReturnType: TypeAny, IsAggregate: false},
+	"JSONB_PATH_QUERY_FIRST_TZ": {Name: "JSONB_PATH_QUERY_FIRST_TZ", MinArgs: 2, MaxArgs: -1, ArgTypes: []Type{TypeAny}, ReturnType: TypeAny, IsAggregate: false},
 }
 
 // LookupFunction returns the function signature for a function name.

+ 28 - 5
pkg/capabilities/capabilities.go

@@ -126,6 +126,19 @@ type JSON struct {
 	Functions      string  `json:"functions"`
 	ArrowOperators Feature `json:"arrow_operators"`
 	InvalidInput   string  `json:"invalid_input"`
+	JSONB          JSONB   `json:"jsonb"`
+}
+
+// JSONB describes the native jsonb type: canonical text storage, operators,
+// functions, ordering, parameters, and the shapes the engine rejects.
+type JSONB struct {
+	Canonicalization string  `json:"canonicalization"`
+	Ordering         string  `json:"ordering"`
+	Operators        Feature `json:"operators"`
+	Functions        Feature `json:"functions"`
+	SetReturning     Feature `json:"set_returning"`
+	Parameters       Feature `json:"parameters"`
+	Rejected         Feature `json:"rejected"`
 }
 
 type Documentation struct {
@@ -204,23 +217,32 @@ func Current() *Document {
 			Boolean:  true,
 			DateTime: true,
 			JSONText: true,
-			JSONB:    Feature{Supported: false, Status: "type_name_alias", Notes: "JSONB is accepted as a column type name with no native semantics; values are stored as text and reported as TEXT over the wire"},
+			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"},
 		},
 
 		Parameters: Parameters{
 			Positional:    true,
-			MaxOID:        "int8/text/bool/bytea/float plus the array OIDs text[]/varchar[]/int4[]/int8[]/int2[]/bool[]/float4[]/float8[]/numeric[]",
+			MaxOID:        "int8/text/bool/bytea/float plus OID 3802 jsonb and the array OIDs text[]/varchar[]/int4[]/int8[]/int2[]/bool[]/float4[]/float8[]/numeric[]",
 			Arrays:        Feature{Supported: true, Status: "text_format", SQLState: "0A000", Notes: "a text-format array parameter is bound as a typed array literal, so id = ANY($1) works with OIDs 1009 text[], 1015 varchar[], 1007 int4[], 1016 int8[], 1005 int2[], 1000 bool[], 1021 float4[], 1022 float8[], and 1231 numeric[]; elements are validated and canonicalized; binary-format array parameters are rejected with 0A000 instead of being misread"},
 			BinaryNumeric: true,
 		},
 
 		JSON: JSON{
-			Storage:        "TEXT (JSON and JSONB type names are accepted without native semantics; insert JSON as text)",
+			Storage:        "JSON is TEXT with the JSON1 surface; JSONB is a native type stored as canonical JSON text (see types.jsonb)",
 			Functions:      "json, json_extract, json_set, json_insert, json_replace, json_remove, json_valid, json_type, json_array, json_array_length, json_object, json_quote, json_group_array; PostgreSQL text compatibility: concat_ws, string_agg, pg_backend_pid",
-			ArrowOperators: Feature{Supported: true, Status: "supported", Notes: "-> returns JSON text for a member or element, ->> returns the SQL value (text, number, 1/0 for booleans, NULL for JSON null); right operand is a label, an array index, or a $ path; missing paths and NULL operands return NULL; chaining is left-to-right"},
-			InvalidInput:   "json_extract/json_set and ->/->> throw 'malformed JSON'; json_valid returns 0",
+			ArrowOperators: Feature{Supported: true, Status: "supported", Notes: "on TEXT/JSON, -> returns JSON text for a member or element and ->> returns the SQL value (text, number, 1/0 for booleans, NULL for JSON null); right operand is a label, an array index, or a $ path; missing paths and NULL operands return NULL; chaining is left-to-right; on jsonb, ->> returns PostgreSQL text rendering (true/false, exact decimal numbers) and a JSON null member is SQL NULL"},
+			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 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 (a scalar string never matches); ?| 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 is duplicate-insensitive (each candidate element only needs some matching container element, as in PostgreSQL); 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_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"},
+			},
 		},
 
 		ErrorCodes: map[string]string{
@@ -230,6 +252,7 @@ func Current() *Document {
 			"23514": "check_violation",
 			"40001": "serialization_failure",
 			"0A000": "feature_not_supported",
+			"22023": "invalid_parameter_value",
 			"42501": "insufficient_privilege (managed proxy)",
 			"42601": "syntax_error",
 			"25P02": "in_failed_sql_transaction",

+ 79 - 0
pkg/capabilities/capabilities_test.go

@@ -221,3 +221,82 @@ func TestArraysAreAdvertised(t *testing.T) {
 		t.Fatalf("parameters.max_oid = %q, want the array OIDs listed", doc.Parameters.MaxOID)
 	}
 }
+
+// JSONB is advertised as a native type with precise canonicalization, operator,
+// function, ordering, parameter, and rejection claims.
+func TestJSONBIsAdvertised(t *testing.T) {
+	doc := Current()
+	types := doc.Types.JSONB
+	if !types.Supported {
+		t.Fatalf("types.jsonb = %+v, want supported", types)
+	}
+	if types.Status != "native" {
+		t.Fatalf("types.jsonb status = %q, want native", types.Status)
+	}
+	for _, want := range []string{"canonical", "sorted", "last-wins", "idempotent", "OID 3802", "22P02", "0A000", "jsonpath", "GIN"} {
+		if !strings.Contains(types.Notes, want) {
+			t.Errorf("types.jsonb notes do not mention %q", want)
+		}
+	}
+	if !doc.Types.JSONText {
+		t.Fatal("JSON (text) remains supported and must stay advertised")
+	}
+
+	jsonb := doc.JSON.JSONB
+	for _, want := range []string{"sorted", "last-wins", "1.50", "idempotent"} {
+		if !strings.Contains(jsonb.Canonicalization, want) {
+			t.Errorf("json.jsonb.canonicalization does not mention %q", want)
+		}
+	}
+	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)
+		}
+	}
+	if !jsonb.Operators.Supported || jsonb.Operators.SQLState != pgerror.CodeDatatypeMismatch {
+		t.Fatalf("json.jsonb.operators = %+v", jsonb.Operators)
+	}
+	for _, want := range []string{"@>", "<@", "?|", "?&", "#>", "#>>", "#-", "||", "scalar string never matches", "SQL NULL"} {
+		if !strings.Contains(jsonb.Operators.Notes, want) {
+			t.Errorf("json.jsonb.operators notes do not mention %q", want)
+		}
+	}
+	if !jsonb.Functions.Supported || jsonb.Functions.SQLState != pgerror.CodeInvalidParameter {
+		t.Fatalf("json.jsonb.functions = %+v", jsonb.Functions)
+	}
+	for _, want := range []string{"jsonb_typeof", "jsonb_array_length", "jsonb_strip_nulls", "jsonb_pretty", "jsonb_set", "jsonb_insert", "jsonb_build_object", "jsonb_build_array", "to_jsonb"} {
+		if !strings.Contains(jsonb.Functions.Notes, want) {
+			t.Errorf("json.jsonb.functions notes do not mention %q", want)
+		}
+	}
+	if !jsonb.SetReturning.Supported || jsonb.SetReturning.Status != "single_from_item" {
+		t.Fatalf("json.jsonb.set_returning = %+v", jsonb.SetReturning)
+	}
+	for _, want := range []string{"jsonb_each", "jsonb_array_elements", "jsonb_object_keys", "0A000"} {
+		if !strings.Contains(jsonb.SetReturning.Notes, want) {
+			t.Errorf("json.jsonb.set_returning notes do not mention %q", want)
+		}
+	}
+	if !jsonb.Parameters.Supported || jsonb.Parameters.Status != "text_and_binary" {
+		t.Fatalf("json.jsonb.parameters = %+v", jsonb.Parameters)
+	}
+	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", "wildcard"} {
+		if !strings.Contains(jsonb.Rejected.Notes, want) {
+			t.Errorf("json.jsonb.rejected notes do not mention %q", want)
+		}
+	}
+	if !strings.Contains(doc.Parameters.MaxOID, "3802") {
+		t.Fatalf("parameters.max_oid = %q, want OID 3802 listed", doc.Parameters.MaxOID)
+	}
+	if got := doc.ErrorCodes[pgerror.CodeInvalidParameter]; got != "invalid_parameter_value" {
+		t.Fatalf("22023 mapping = %q, want invalid_parameter_value", got)
+	}
+}

+ 23 - 0
pkg/executor/arrays.go

@@ -634,6 +634,29 @@ func (e *Executor) arrayEqualityType(leftExpr, rightExpr parser.Expr) (string, b
 // NUMERIC-typed operand compares exactly (including element-wise numeric array
 // 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))
+		if err != nil {
+			return false, err
+		}
+		rightText, err := CanonicalizeJSONB(toString(right))
+		if err != nil {
+			return false, err
+		}
+		// 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 {
 			equal, err := numericArrayTextEqual(toString(left), toString(right), typeName)

+ 158 - 22
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])
@@ -1558,13 +1570,13 @@ func (e *Executor) executeSelectExpr(stmt *parser.SelectStmt) (*Result, error) {
 	return result, nil
 }
 
-// executeSelectFromTableFunction materializes unnest(array) as a one-column
+// executeSelectFromTableFunction materializes a set-returning function as a
 // relation and runs the rest of the SELECT over it. The parser restricts this
 // form to a single FROM entry with no joins.
 func (e *Executor) executeSelectFromTableFunction(stmt *parser.SelectStmt) (*Result, error) {
 	ref := stmt.From[0]
 	if !strings.EqualFold(ref.Function.Name, "unnest") {
-		return nil, pgerror.Unsupported("table function %s() is not supported", ref.Function.Name)
+		return e.executeSelectFromJSONBTableFunction(stmt, ref)
 	}
 	if len(ref.Function.Args) != 1 {
 		return nil, fmt.Errorf("unnest() requires exactly one array argument")
@@ -1617,6 +1629,50 @@ func (e *Executor) executeSelectFromTableFunction(stmt *parser.SelectStmt) (*Res
 	return result, nil
 }
 
+// executeSelectFromJSONBTableFunction materializes one of the jsonb
+// set-returning functions (jsonb_each, jsonb_array_elements, and their _text
+// forms) as a typed relation.
+func (e *Executor) executeSelectFromJSONBTableFunction(stmt *parser.SelectStmt, ref parser.TableRef) (*Result, error) {
+	name := strings.ToLower(ref.Function.Name)
+	if len(ref.Function.Args) != 1 {
+		return nil, pgerror.InvalidParameter("%s() requires exactly one jsonb argument", name)
+	}
+	value, err := e.evalExpr(ref.Function.Args[0], nil)
+	if err != nil {
+		return nil, fmt.Errorf("%s argument: %w", name, err)
+	}
+	columns, types, rows, err := jsonbTableFunction(name, value)
+	if err != nil {
+		return nil, err
+	}
+	qualifier := ref.Alias
+	if qualifier == "" {
+		qualifier = ref.Name
+	}
+	result, err := e.executeSelectOnMaterializedTyped(stmt, columns, types, rows, qualifier)
+	if err != nil {
+		return nil, err
+	}
+	resolved := make([]string, 0, len(result.Columns))
+	resolve := func(_, column string) (string, bool) {
+		for i, candidate := range columns {
+			if strings.EqualFold(candidate, column) {
+				return typeAt(types, i), true
+			}
+		}
+		return "", false
+	}
+	for _, projection := range stmt.Columns {
+		if projection.Star || projection.TableStar != "" {
+			resolved = append(resolved, types...)
+			continue
+		}
+		resolved = append(resolved, inferExprType(projection.Expr, resolve))
+	}
+	result.ColumnTypes = resolved
+	return result, nil
+}
+
 // executeSelectFromSubquery executes a SELECT with a subquery in FROM clause.
 // The derived table's column types are carried into the outer statement so
 // array subscripts and comparisons keep decoding elements.
@@ -2904,6 +2960,9 @@ func (e *Executor) applyUpsert(table string, schema *storage.Schema, existing, r
 		if normalizeErr := e.normalizeArrayRow(schema, updates); normalizeErr != nil {
 			return nil, normalizeErr
 		}
+		if normalizeErr := e.normalizeJSONBRow(schema, updates); normalizeErr != nil {
+			return nil, normalizeErr
+		}
 		// Recompute STORED generated columns so an upsert keeps them current.
 		if err := e.applyGeneratedColumns(schema, merged); err != nil {
 			return nil, err
@@ -3115,11 +3174,14 @@ func (e *Executor) executeInsert(stmt *parser.InsertStmt) (*Result, error) {
 // RETURNING. An ON CONFLICT DO UPDATE returns the merged existing row, not the
 // candidate, so RETURNING and unique-target upserts observe the real row.
 func (e *Executor) insertOneRow(tableName string, schema *storage.Schema, row storage.Row, stmt *parser.InsertStmt, wantStored bool) (int64, bool, bool, storage.Row, error) {
-	// Canonicalize array column values once, before conflict matching reads
-	// them and before storage encodes them.
+	// Canonicalize array and jsonb column values once, before conflict matching
+	// reads them and before storage encodes them.
 	if err := e.normalizeArrayRow(schema, row); err != nil {
 		return 0, false, false, nil, err
 	}
+	if err := e.normalizeJSONBRow(schema, row); err != nil {
+		return 0, false, false, nil, err
+	}
 	if stmt.OnConflict == parser.ConflictIgnore || (stmt.ConflictDoNothing && len(stmt.ConflictTarget) == 0) {
 		conflict, err := e.hasAnyInsertConflict(tableName, schema, row)
 		if err != nil {
@@ -3500,6 +3562,9 @@ func (e *Executor) executeUpdate(stmt *parser.UpdateStmt) (*Result, error) {
 		if err := e.normalizeArrayRow(schema, updates); err != nil {
 			return nil, err
 		}
+		if err := e.normalizeJSONBRow(schema, updates); err != nil {
+			return nil, err
+		}
 		if err := e.applyGeneratedColumns(schema, merged); err != nil {
 			return nil, err
 		}
@@ -3688,6 +3753,9 @@ func (e *Executor) executeUpdateFrom(stmt *parser.UpdateStmt, schema *storage.Sc
 		if err := e.normalizeArrayRow(schema, row); err != nil {
 			return nil, err
 		}
+		if err := e.normalizeJSONBRow(schema, row); err != nil {
+			return nil, err
+		}
 		if err := e.applyGeneratedColumns(schema, row); err != nil {
 			return nil, err
 		}
@@ -3989,6 +4057,10 @@ func (e *Executor) executeCreateTable(stmt *parser.CreateTableStmt) (*Result, er
 					if err != nil {
 						return nil, err
 					}
+					val, exprText, err = canonicalJSONBDefault(col.Type, val, exprText)
+					if err != nil {
+						return nil, err
+					}
 					col.Default = val
 					col.DefaultExpr = exprText
 				}
@@ -4288,6 +4360,9 @@ func (e *Executor) executeCreateIndex(stmt *parser.CreateIndexStmt) (*Result, er
 			if err := ValidateIndexColumns(col.Expr, schema); err != nil {
 				return nil, err
 			}
+			if strings.EqualFold(inferExprType(col.Expr, schemaTypeResolver(schema)), "JSONB") {
+				return nil, pgerror.Unsupported("expression indexes over jsonb are not supported; index a scalar extraction such as (doc ->> 'key') instead")
+			}
 			continue
 		}
 		if _, found := schema.GetColumn(col.Name); !found {
@@ -4506,6 +4581,10 @@ func (e *Executor) executeAlterTableAddColumn(table string, action *parser.AddCo
 				if err != nil {
 					return nil, err
 				}
+				val, exprText, err = canonicalJSONBDefault(col.Type, val, exprText)
+				if err != nil {
+					return nil, err
+				}
 				col.Default = val
 				col.DefaultExpr = exprText
 			}
@@ -5369,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)
 			}
 		}
 
@@ -5394,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)
 				}
 			}
 		}
@@ -5407,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)
 			}
 		}
 	}
@@ -5433,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)
 		}
 	}
 
@@ -5450,6 +5529,10 @@ func (e *Executor) evalBinaryExpr(expr *parser.BinaryExpr, row storage.Row) (int
 		return nil, err
 	}
 
+	if value, handled, jsonbErr := e.evalJSONBBinary(expr.Op, expr.Left, expr.Right, left, right); handled {
+		return value, jsonbErr
+	}
+
 	if isComparisonOp(expr.Op) {
 		left, right, err = e.canonicalizeArrayComparison(expr.Left, expr.Right, left, right)
 		if err != nil {
@@ -5877,7 +5960,11 @@ func (e *Executor) evalFunctionCall(fn *parser.FunctionCall, row storage.Row) (i
 		args[i] = val
 	}
 
-	// JSON1 scalar functions are dispatched separately to keep this switch tidy.
+	// Native jsonb functions and JSON1 scalar functions are dispatched
+	// separately to keep this switch tidy.
+	if val, handled, err := e.evalJSONBFunction(name, fn, args); handled {
+		return val, err
+	}
 	if val, handled, err := evalJSONFunction(name, args); handled {
 		return val, err
 	}
@@ -6197,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)
@@ -6478,6 +6569,17 @@ func (e *Executor) evalCastExpr(expr *parser.CastExpr, row storage.Row) (interfa
 	}
 
 	typeName := strings.ToUpper(expr.Type.Name)
+	if typeName == "JSONB" {
+		text, ok := val.(string)
+		if !ok {
+			if jsonValue, isJSON := val.(jsonText); isJSON {
+				text = string(jsonValue)
+			} else {
+				return nil, pgerror.InvalidText("cannot cast %T to JSONB", val)
+			}
+		}
+		return CanonicalizeJSONB(text)
+	}
 	switch {
 	case strings.Contains(typeName, "INT"):
 		return int64(toFloat(val)), nil
@@ -6977,6 +7079,9 @@ func (e *Executor) evalExprWithAggregates(expr parser.Expr, rows []storage.Row)
 		if err != nil {
 			return nil, err
 		}
+		if value, handled, jsonbErr := e.evalJSONBBinary(ex.Op, ex.Left, ex.Right, left, right); handled {
+			return value, jsonbErr
+		}
 		if isComparisonOp(ex.Op) {
 			left, right, err = e.canonicalizeArrayComparison(ex.Left, ex.Right, left, right)
 			if err != nil {
@@ -7004,6 +7109,12 @@ func (e *Executor) evalExprWithAggregates(expr parser.Expr, rows []storage.Row)
 			}
 			args[i] = v
 		}
+		// Keep the real argument expressions for jsonb: to_jsonb and the build
+		// functions need expression types to embed a jsonb document instead of
+		// quoting it as text.
+		if val, handled, err := e.evalJSONBFunction(name, ex, args); handled {
+			return val, err
+		}
 		return e.evalBuiltinFunction(name, args)
 	case *parser.ParenExpr:
 		return e.evalExprWithAggregates(ex.Expr, rows)
@@ -7043,6 +7154,17 @@ func (e *Executor) evalExprWithAggregates(expr parser.Expr, rows []storage.Row)
 		if ex.Type.Array {
 			return evalArrayCast(ex.Type, val)
 		}
+		if strings.EqualFold(ex.Type.Name, "JSONB") {
+			text, ok := val.(string)
+			if !ok {
+				if jsonValue, isJSON := val.(jsonText); isJSON {
+					text = string(jsonValue)
+				} else {
+					return nil, pgerror.InvalidText("cannot cast %T to JSONB", val)
+				}
+			}
+			return CanonicalizeJSONB(text)
+		}
 		switch strings.ToUpper(ex.Type.Name) {
 		case "INTEGER", "INT", "BIGINT", "SMALLINT", "TINYINT", "SIGNED":
 			if isIntVal(val) {
@@ -7064,7 +7186,13 @@ func (e *Executor) evalExprWithAggregates(expr parser.Expr, rows []storage.Row)
 			condVal, _ := e.evalExprWithAggregates(when.Condition, rows)
 			var matched bool
 			if ex.Operand != nil {
-				matched = operand != nil && condVal != nil && compare(operand, condVal) == 0
+				if operand != nil && condVal != nil {
+					equal, cmpErr := e.compareExprValues(lexer.TokenEq, ex.Operand, when.Condition, operand, condVal)
+					if cmpErr != nil {
+						return nil, cmpErr
+					}
+					matched = equal
+				}
 			} else {
 				matched = toBool(condVal)
 			}
@@ -7095,6 +7223,10 @@ func (e *Executor) evalExprWithAggregates(expr parser.Expr, rows []storage.Row)
 		if err != nil {
 			return nil, err
 		}
+		left, right, err = e.canonicalizeArrayComparison(ex.Left, ex.Right, left, right)
+		if err != nil {
+			return nil, err
+		}
 		distinct := false
 		switch {
 		case left == nil && right == nil:
@@ -7102,7 +7234,11 @@ func (e *Executor) evalExprWithAggregates(expr parser.Expr, rows []storage.Row)
 		case left == nil || right == nil:
 			distinct = true
 		default:
-			distinct = compare(left, right) != 0
+			equal, cmpErr := e.compareExprValues(lexer.TokenEq, ex.Left, ex.Right, left, right)
+			if cmpErr != nil {
+				return nil, cmpErr
+			}
+			distinct = !equal
 		}
 		if ex.Not {
 			return !distinct, nil

+ 4 - 0
pkg/executor/indexexpr.go

@@ -213,6 +213,10 @@ var deterministicFunctions = map[string]bool{
 	"JSON_REPLACE": true, "JSONB_REPLACE": true, "JSON_REMOVE": true, "JSONB_REMOVE": true,
 	"JSON_ARRAY": true, "JSONB_ARRAY": true, "JSON_OBJECT": true, "JSONB_OBJECT": true,
 	"JSON_QUOTE": true, "JSONB_QUOTE": true,
+	// Native JSONB scalar functions.
+	"TO_JSONB": true, "JSONB_TYPEOF": true, "JSONB_ARRAY_LENGTH": true,
+	"JSONB_STRIP_NULLS": true, "JSONB_PRETTY": true, "JSONB_BUILD_OBJECT": true,
+	"JSONB_BUILD_ARRAY": true,
 }
 
 func isDeterministicFunction(name string) bool {

+ 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{})

+ 1852 - 0
pkg/executor/jsonb.go

@@ -0,0 +1,1852 @@
+package executor
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"io"
+	"math/big"
+	"sort"
+	"strconv"
+	"strings"
+	"unicode/utf8"
+
+	"github.com/danfragoso/pizzasql-next/pkg/lexer"
+	"github.com/danfragoso/pizzasql-next/pkg/parser"
+	"github.com/danfragoso/pizzasql-next/pkg/pgerror"
+	"github.com/danfragoso/pizzasql-next/pkg/storage"
+)
+
+// JSONB values are carried as canonical JSON text: object keys sorted, duplicate
+// keys collapsed last-wins, no insignificant whitespace, and numbers written as
+// exact normalized decimals. The canonical form is idempotent and is what every
+// write path stores, so a reload returns the same bytes and equality can be
+// defined on them.
+//
+// A JSONB column is distinguished from JSON/TEXT by its declared type. The
+// executor resolves that type from the statement's column-type scopes (the same
+// mechanism arrays use), so operators and functions know whether a text value is
+// a jsonb document or ordinary SQL text.
+
+// maxJSONBNumberDigits bounds the decimal expansion of a jsonb number. It
+// matches PostgreSQL's numeric digit limit and keeps a short exponent such as
+// 1e1000000 from allocating an enormous canonical string.
+const maxJSONBNumberDigits = 131072
+
+// CanonicalizeJSONB validates JSON text and returns its canonical JSONB form.
+// It is exported so the protocol layer can canonicalize a text-format jsonb
+// parameter at Bind time.
+func CanonicalizeJSONB(text string) (string, error) {
+	node, err := decodeJSONB(text)
+	if err != nil {
+		return "", invalidJSONBInput(text)
+	}
+	canonical, err := encodeJSONB(node, false)
+	if err != nil {
+		return "", invalidJSONBInput(text)
+	}
+	return canonical, nil
+}
+
+func invalidJSONBInput(text string) error {
+	return pgerror.InvalidText("invalid input syntax for type jsonb: %q", text)
+}
+
+// decodeJSONB parses one JSON document strictly. Duplicate object keys collapse
+// last-wins (encoding/json's map decoding), invalid UTF-8 and trailing content
+// are rejected, and numbers stay exact as json.Number.
+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{}
+	if err := dec.Decode(&node); err != nil {
+		return nil, invalidJSONBInput(text)
+	}
+	var trailing interface{}
+	if err := dec.Decode(&trailing); err != io.EOF {
+		return nil, invalidJSONBInput(text)
+	}
+	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) {
+	var buf bytes.Buffer
+	if err := writeJSONBNode(&buf, node, pretty, 0); err != nil {
+		return "", err
+	}
+	return buf.String(), nil
+}
+
+func writeJSONBNode(buf *bytes.Buffer, node interface{}, pretty bool, depth int) error {
+	switch value := node.(type) {
+	case nil:
+		buf.WriteString("null")
+	case bool:
+		if value {
+			buf.WriteString("true")
+		} else {
+			buf.WriteString("false")
+		}
+	case json.Number:
+		text, err := canonicalJSONBNumber(value)
+		if err != nil {
+			return err
+		}
+		buf.WriteString(text)
+	case string:
+		writeJSONBString(buf, value)
+	case []interface{}:
+		if len(value) == 0 {
+			buf.WriteString("[]")
+			return nil
+		}
+		buf.WriteByte('[')
+		for i, element := range value {
+			if i > 0 {
+				buf.WriteByte(',')
+			}
+			if pretty {
+				buf.WriteByte('\n')
+				writeJSONBIndent(buf, depth+1)
+			}
+			if err := writeJSONBNode(buf, element, pretty, depth+1); err != nil {
+				return err
+			}
+		}
+		if pretty {
+			buf.WriteByte('\n')
+			writeJSONBIndent(buf, depth)
+		}
+		buf.WriteByte(']')
+	case map[string]interface{}:
+		if len(value) == 0 {
+			buf.WriteString("{}")
+			return nil
+		}
+		keys := make([]string, 0, len(value))
+		for key := range value {
+			keys = append(keys, key)
+		}
+		sort.Strings(keys)
+		buf.WriteByte('{')
+		for i, key := range keys {
+			if i > 0 {
+				buf.WriteByte(',')
+			}
+			if pretty {
+				buf.WriteByte('\n')
+				writeJSONBIndent(buf, depth+1)
+			}
+			writeJSONBString(buf, key)
+			buf.WriteByte(':')
+			if pretty {
+				buf.WriteByte(' ')
+			}
+			if err := writeJSONBNode(buf, value[key], pretty, depth+1); err != nil {
+				return err
+			}
+		}
+		if pretty {
+			buf.WriteByte('\n')
+			writeJSONBIndent(buf, depth)
+		}
+		buf.WriteByte('}')
+	default:
+		return pgerror.InvalidParameter("unsupported jsonb node type %T", node)
+	}
+	return nil
+}
+
+func writeJSONBIndent(buf *bytes.Buffer, depth int) {
+	for i := 0; i < depth; i++ {
+		buf.WriteString("    ")
+	}
+}
+
+// writeJSONBString writes a JSON string literal without HTML escaping and
+// without rewriting valid UTF-8. Control characters use the short escapes JSON
+// defines, or \u00XX for the rest.
+func writeJSONBString(buf *bytes.Buffer, value string) {
+	buf.WriteByte('"')
+	for _, r := range value {
+		switch r {
+		case '"':
+			buf.WriteString(`\"`)
+		case '\\':
+			buf.WriteString(`\\`)
+		case '\n':
+			buf.WriteString(`\n`)
+		case '\r':
+			buf.WriteString(`\r`)
+		case '\t':
+			buf.WriteString(`\t`)
+		case '\b':
+			buf.WriteString(`\b`)
+		case '\f':
+			buf.WriteString(`\f`)
+		default:
+			if r < 0x20 {
+				const hex = "0123456789abcdef"
+				buf.WriteString(`\u00`)
+				buf.WriteByte(hex[byte(r)>>4])
+				buf.WriteByte(hex[byte(r)&0x0f])
+				continue
+			}
+			buf.WriteRune(r)
+		}
+	}
+	buf.WriteByte('"')
+}
+
+// canonicalJSONBNumber normalizes a JSON number to an exact decimal spelling:
+// no exponent, no trailing fractional zeros, and no negative zero. The input is
+// a finite decimal, so the exact expansion always terminates.
+func canonicalJSONBNumber(number json.Number) (string, error) {
+	text := string(number)
+	negative := false
+	i := 0
+	if i < len(text) && text[i] == '-' {
+		negative = true
+		i++
+	}
+	start := i
+	for i < len(text) && text[i] >= '0' && text[i] <= '9' {
+		i++
+	}
+	digits := text[start:i]
+	if digits == "" {
+		return "", invalidJSONBInput(text)
+	}
+	fraction := ""
+	if i < len(text) && text[i] == '.' {
+		i++
+		fractionStart := i
+		for i < len(text) && text[i] >= '0' && text[i] <= '9' {
+			i++
+		}
+		fraction = text[fractionStart:i]
+	}
+	exponent := 0
+	if i < len(text) && (text[i] == 'e' || text[i] == 'E') {
+		i++
+		parsed, err := strconv.Atoi(text[i:])
+		if err != nil {
+			return "", invalidJSONBInput(text)
+		}
+		exponent = parsed
+	} else if i != len(text) {
+		return "", invalidJSONBInput(text)
+	}
+
+	allDigits := digits + fraction
+	if len(allDigits) > maxJSONBNumberDigits {
+		return "", pgerror.InvalidText("jsonb number field overflow")
+	}
+	mantissa := new(big.Int)
+	if _, ok := mantissa.SetString(allDigits, 10); !ok {
+		return "", invalidJSONBInput(text)
+	}
+	if mantissa.Sign() == 0 {
+		return "0", nil
+	}
+
+	// value = mantissa / 10^scale, where scale = len(fraction) - exponent.
+	scale := len(fraction) - exponent
+	rendered := mantissa.String()
+	if scale <= 0 {
+		if len(rendered)-scale > maxJSONBNumberDigits {
+			return "", pgerror.InvalidText("jsonb number field overflow")
+		}
+		rendered += strings.Repeat("0", -scale)
+	} else {
+		// Strip trailing zeros in the fractional part before rendering.
+		for scale > 0 && len(rendered) > 1 && rendered[len(rendered)-1] == '0' {
+			rendered = rendered[:len(rendered)-1]
+			scale--
+		}
+		if scale >= len(rendered) {
+			if scale > maxJSONBNumberDigits {
+				return "", pgerror.InvalidText("jsonb number field overflow")
+			}
+			rendered = "0." + strings.Repeat("0", scale-len(rendered)) + rendered
+		} else if scale > 0 {
+			rendered = rendered[:len(rendered)-scale] + "." + rendered[len(rendered)-scale:]
+		}
+	}
+	if negative {
+		return "-" + rendered, nil
+	}
+	return rendered, nil
+}
+
+// jsonbNodeFromArg resolves a jsonb function or operator operand to a decoded
+// tree. NULL stays SQL NULL; text and the JSON subtype are parsed as a document.
+func jsonbNodeFromArg(value interface{}) (interface{}, error) {
+	switch typed := value.(type) {
+	case nil:
+		return nil, nil
+	case string:
+		return decodeJSONB(typed)
+	case jsonText:
+		return decodeJSONB(string(typed))
+	case []byte:
+		return decodeJSONB(string(typed))
+	default:
+		return nil, pgerror.DatatypeMismatch("jsonb value must be JSON text, got %T", value)
+	}
+}
+
+// jsonbNodeFromSQLValue converts a SQL value into a JSON tree node for
+// to_jsonb, jsonb_build_array, and jsonb_build_object values. NULL maps to JSON
+// null; callers decide whether a top-level NULL stays SQL NULL.
+func jsonbNodeFromSQLValue(value interface{}) (interface{}, error) {
+	switch typed := value.(type) {
+	case nil:
+		return nil, nil
+	case bool:
+		return typed, nil
+	case int:
+		return json.Number(strconv.FormatInt(int64(typed), 10)), nil
+	case int8:
+		return json.Number(strconv.FormatInt(int64(typed), 10)), nil
+	case int16:
+		return json.Number(strconv.FormatInt(int64(typed), 10)), nil
+	case int32:
+		return json.Number(strconv.FormatInt(int64(typed), 10)), nil
+	case int64:
+		return json.Number(strconv.FormatInt(typed, 10)), nil
+	case uint:
+		return json.Number(strconv.FormatUint(uint64(typed), 10)), nil
+	case uint32:
+		return json.Number(strconv.FormatUint(uint64(typed), 10)), nil
+	case uint64:
+		return json.Number(strconv.FormatUint(typed, 10)), nil
+	case float32:
+		return json.Number(strconv.FormatFloat(float64(typed), 'g', -1, 32)), nil
+	case float64:
+		if typed != typed {
+			return nil, pgerror.InvalidParameter("cannot convert NaN to jsonb")
+		}
+		if typed > 1.7976931348623157e308 || typed < -1.7976931348623157e308 {
+			return nil, pgerror.InvalidParameter("cannot convert infinity to jsonb")
+		}
+		return json.Number(strconv.FormatFloat(typed, 'g', -1, 64)), nil
+	case string:
+		return typed, nil
+	case jsonText:
+		return string(typed), nil
+	case []byte:
+		return string(typed), nil
+	default:
+		return toString(value), nil
+	}
+}
+
+// jsonbNodeToText renders a jsonb node as the SQL text that ->> and the *_text
+// set-returning functions return. JSON null becomes SQL NULL.
+func jsonbNodeToText(node interface{}) (interface{}, error) {
+	switch value := node.(type) {
+	case nil:
+		return nil, nil
+	case bool:
+		if value {
+			return "true", nil
+		}
+		return "false", nil
+	case json.Number:
+		return canonicalJSONBNumber(value)
+	case string:
+		return value, nil
+	default:
+		return encodeJSONB(node, false)
+	}
+}
+
+// isJSONBExpr reports whether an expression produces a jsonb value. It follows
+// the statement's column-type scopes, so a JSONB column, a cast, and a jsonb
+// function are all recognized even though their runtime carrier is text.
+func (e *Executor) isJSONBExpr(expr parser.Expr) bool {
+	switch typed := expr.(type) {
+	case nil:
+		return false
+	case *parser.ParenExpr:
+		return e.isJSONBExpr(typed.Expr)
+	case *parser.CastExpr:
+		return strings.EqualFold(strings.TrimSpace(typed.Type.Name), "JSONB")
+	case *parser.BinaryExpr:
+		switch typed.Op {
+		case lexer.TokenArrow, lexer.TokenHashArrow, lexer.TokenHashMinus:
+			return e.isJSONBExpr(typed.Left)
+		case lexer.TokenConcat:
+			return e.isJSONBExpr(typed.Left) || e.isJSONBExpr(typed.Right)
+		case lexer.TokenMinus:
+			return e.isJSONBExpr(typed.Left)
+		}
+		return false
+	case *parser.FunctionCall:
+		if jsonbFunctionReturnsJSONB(typed.Name) {
+			return true
+		}
+	}
+	return strings.EqualFold(strings.TrimSpace(inferExprType(expr, e.resolveColumnType)), "JSONB")
+}
+
+// jsonbFunctionReturnsJSONB reports whether a scalar function returns jsonb.
+func jsonbFunctionReturnsJSONB(name string) bool {
+	switch strings.ToUpper(name) {
+	case "TO_JSONB", "JSONB_SET", "JSONB_INSERT", "JSONB_REPLACE", "JSONB_REMOVE",
+		"JSONB_STRIP_NULLS", "JSONB_BUILD_OBJECT", "JSONB_BUILD_ARRAY":
+		return true
+	default:
+		return false
+	}
+}
+
+// evalJSONBBinary applies the jsonb operators. handled is false when the operator
+// and operands are not a jsonb form, leaving ordinary SQL semantics untouched.
+func (e *Executor) evalJSONBBinary(op lexer.TokenType, leftExpr, rightExpr parser.Expr, left, right interface{}) (interface{}, bool, error) {
+	leftJSONB := e.isJSONBExpr(leftExpr)
+	rightJSONB := e.isJSONBExpr(rightExpr)
+
+	if isComparisonOp(op) && (leftJSONB || rightJSONB) {
+		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
+		}
+		rightText, err := CanonicalizeJSONB(toString(right))
+		if err != nil {
+			return nil, true, err
+		}
+		result, err := comparisonOperatorResult(op, strings.Compare(leftText, rightText))
+		return result, true, err
+	}
+
+	switch op {
+	case lexer.TokenArrow, lexer.TokenArrowText:
+		if !leftJSONB {
+			return nil, false, nil
+		}
+		value, err := jsonbArrowExtract(left, right, op == lexer.TokenArrowText)
+		return value, true, err
+
+	case lexer.TokenHashArrow, lexer.TokenHashArrowText:
+		value, err := jsonbPathExtract(left, right, op == lexer.TokenHashArrowText)
+		return value, true, err
+
+	case lexer.TokenHashMinus:
+		value, err := jsonbPathDelete(left, right)
+		return value, true, err
+
+	case lexer.TokenAtArrow, lexer.TokenLtAt:
+		value, err := e.jsonbContainment(op, leftExpr, rightExpr, left, right)
+		return value, true, err
+
+	case lexer.TokenQuestion:
+		value, err := jsonbKeyExists(left, right)
+		return value, true, err
+
+	case lexer.TokenQuestionPipe, lexer.TokenQuestionAmp:
+		value, err := jsonbAnyKeysExist(op, left, right)
+		return value, true, err
+
+	case lexer.TokenConcat:
+		if !leftJSONB && !rightJSONB {
+			return nil, false, nil
+		}
+		if left == nil || right == nil {
+			return nil, true, nil
+		}
+		leftNode, err := jsonbNodeFromArg(left)
+		if err != nil {
+			return nil, true, err
+		}
+		rightNode, err := jsonbNodeFromArg(right)
+		if err != nil {
+			return nil, true, err
+		}
+		text, err := encodeJSONB(jsonbConcatNodes(leftNode, rightNode), false)
+		return text, true, err
+
+	case lexer.TokenMinus:
+		if !leftJSONB {
+			if rightJSONB {
+				return nil, true, pgerror.DatatypeMismatch("cannot subtract a jsonb value from a non-jsonb value")
+			}
+			return nil, false, nil
+		}
+		if left == nil || right == nil {
+			return nil, true, nil
+		}
+		value, err := e.jsonbDeleteValue(leftExpr, rightExpr, left, right)
+		return value, true, err
+
+	case lexer.TokenPlus, lexer.TokenStar, lexer.TokenSlash, lexer.TokenPercent:
+		if leftJSONB || rightJSONB {
+			return nil, true, pgerror.DatatypeMismatch("arithmetic operator %s requires numeric operands, got jsonb", op.String())
+		}
+	}
+	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.
+func jsonbArrowPath(key interface{}) ([]jsonPathSeg, error) {
+	switch typed := key.(type) {
+	case string:
+		if strings.HasPrefix(strings.TrimSpace(typed), "$") {
+			return parseJSONBJSONPath(typed)
+		}
+		return jsonArrowPath(key)
+	case jsonText:
+		if strings.HasPrefix(strings.TrimSpace(string(typed)), "$") {
+			return parseJSONBJSONPath(string(typed))
+		}
+		return jsonArrowPath(key)
+	default:
+		return jsonArrowPath(key)
+	}
+}
+
+// jsonbArrowExtract evaluates -> and ->> against a jsonb document. A missing
+// path is SQL NULL; a JSON null member is the jsonb value "null" for -> and SQL
+// NULL for ->>.
+func jsonbArrowExtract(document, key interface{}, scalar bool) (interface{}, error) {
+	if document == nil || key == nil {
+		return nil, nil
+	}
+	node, err := jsonbNodeFromArg(document)
+	if err != nil {
+		return nil, err
+	}
+	segs, err := jsonbArrowPath(key)
+	if err != nil {
+		return nil, err
+	}
+	found, ok := jsonLookup(node, segs)
+	if !ok {
+		return nil, nil
+	}
+	if scalar {
+		return jsonbNodeToText(found)
+	}
+	return encodeJSONB(found, false)
+}
+
+// jsonbPathExtract evaluates #> and #>>. The path operand is a PostgreSQL
+// text[] (canonical array text) or a $ path.
+func jsonbPathExtract(document, path interface{}, scalar bool) (interface{}, error) {
+	if document == nil || path == nil {
+		return nil, nil
+	}
+	node, err := jsonbNodeFromArg(document)
+	if err != nil {
+		return nil, err
+	}
+	segs, err := jsonbPathArg(path)
+	if err != nil {
+		return nil, err
+	}
+	found, ok, err := jsonLookupE(node, segs)
+	if err != nil {
+		return nil, err
+	}
+	if !ok {
+		return nil, nil
+	}
+	if scalar {
+		return jsonbNodeToText(found)
+	}
+	return encodeJSONB(found, false)
+}
+
+// jsonbPathDelete evaluates #-. A missing path leaves the document unchanged.
+func jsonbPathDelete(document, path interface{}) (interface{}, error) {
+	if document == nil || path == nil {
+		return nil, nil
+	}
+	node, err := jsonbNodeFromArg(document)
+	if err != nil {
+		return nil, err
+	}
+	segs, err := jsonbPathArg(path)
+	if err != nil {
+		return nil, err
+	}
+	updated, err := jsonRemove(node, segs)
+	if err != nil {
+		return nil, err
+	}
+	return encodeJSONB(updated, false)
+}
+
+// jsonbContainment evaluates @> and <@. The jsonb operand must be present on at
+// least one side so a text-only comparison is not silently reinterpreted.
+func (e *Executor) jsonbContainment(op lexer.TokenType, leftExpr, rightExpr parser.Expr, left, right interface{}) (interface{}, error) {
+	leftJSONB := e.isJSONBExpr(leftExpr)
+	rightJSONB := e.isJSONBExpr(rightExpr)
+	if !leftJSONB && !rightJSONB {
+		return nil, pgerror.DatatypeMismatch("jsonb containment requires a jsonb operand; cast one side with ::jsonb")
+	}
+	if left == nil || right == nil {
+		return nil, nil
+	}
+	leftNode, err := jsonbNodeFromArg(left)
+	if err != nil {
+		return nil, err
+	}
+	rightNode, err := jsonbNodeFromArg(right)
+	if err != nil {
+		return nil, err
+	}
+	switch op {
+	case lexer.TokenAtArrow:
+		return jsonbContains(leftNode, rightNode), nil
+	case lexer.TokenLtAt:
+		return jsonbContains(rightNode, leftNode), nil
+	}
+	return nil, pgerror.InvalidParameter("unsupported jsonb containment operator")
+}
+
+// jsonbKeyExists evaluates the ? operator: an object key or a top-level string
+// array element.
+func jsonbKeyExists(left, right interface{}) (interface{}, error) {
+	if left == nil || right == nil {
+		return nil, nil
+	}
+	node, err := jsonbNodeFromArg(left)
+	if err != nil {
+		return nil, err
+	}
+	key := toString(right)
+	switch value := node.(type) {
+	case map[string]interface{}:
+		_, exists := value[key]
+		return exists, nil
+	case []interface{}:
+		for _, element := range value {
+			if text, ok := element.(string); ok && text == key {
+				return true, nil
+			}
+		}
+		return false, nil
+	default:
+		// Scalar strings never match: jsonb_exists only looks at object keys and
+		// top-level string array elements.
+		return false, nil
+	}
+}
+
+// jsonbAnyKeysExist evaluates ?| and ?& against a text[] of keys.
+func jsonbAnyKeysExist(op lexer.TokenType, left, right interface{}) (interface{}, error) {
+	if left == nil || right == nil {
+		return nil, nil
+	}
+	node, err := jsonbNodeFromArg(left)
+	if err != nil {
+		return nil, err
+	}
+	keys, err := jsonbKeyListArg(right)
+	if err != nil {
+		return nil, err
+	}
+	matches := 0
+	switch value := node.(type) {
+	case map[string]interface{}:
+		for _, key := range keys {
+			if _, exists := value[key]; exists {
+				matches++
+			}
+		}
+	case []interface{}:
+		for _, key := range keys {
+			for _, element := range value {
+				if text, ok := element.(string); ok && text == key {
+					matches++
+					break
+				}
+			}
+		}
+	default:
+		return false, nil
+	}
+	if op == lexer.TokenQuestionAmp {
+		return matches == len(keys), nil
+	}
+	return matches > 0, nil
+}
+
+// jsonbDeleteValue evaluates jsonb - text, jsonb - integer, and jsonb - text[].
+func (e *Executor) jsonbDeleteValue(leftExpr, rightExpr parser.Expr, left, right interface{}) (interface{}, error) {
+	if e.isJSONBExpr(rightExpr) {
+		return nil, pgerror.DatatypeMismatch("jsonb deletion requires a text key, integer index, or text[] path, not a jsonb operand")
+	}
+	node, err := jsonbNodeFromArg(left)
+	if err != nil {
+		return nil, err
+	}
+	if _, isArray := e.arrayTypeOf(rightExpr); isArray {
+		elements, parseErr := parseArrayLiteral(toString(right))
+		if parseErr != nil {
+			return nil, parseErr
+		}
+		keys := make([]string, 0, len(elements))
+		for _, element := range elements {
+			if element == nil {
+				return nil, pgerror.InvalidParameter("jsonb delete path contains a NULL element")
+			}
+			keys = append(keys, *element)
+		}
+		return encodeJSONB(jsonbDeleteKeys(node, keys), false)
+	}
+	switch value := right.(type) {
+	case int64:
+		array, ok := node.([]interface{})
+		if !ok {
+			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:
+		return e.jsonbDeleteValue(leftExpr, rightExpr, left, int64(value))
+	case string, jsonText:
+		// jsonb - text deletes an object key, or every string element equal to
+		// the text when the document is an array.
+		key := toString(value)
+		return encodeJSONB(jsonbDeleteKeys(node, []string{key}), false)
+	default:
+		return nil, pgerror.DatatypeMismatch("cannot delete %T from jsonb", right)
+	}
+}
+
+func jsonbDeleteKeys(node interface{}, keys []string) interface{} {
+	switch container := node.(type) {
+	case map[string]interface{}:
+		out := make(map[string]interface{}, len(container))
+		for key, value := range container {
+			out[key] = value
+		}
+		for _, key := range keys {
+			delete(out, key)
+		}
+		return out
+	case []interface{}:
+		out := make([]interface{}, 0, len(container))
+		for _, element := range container {
+			remove := false
+			if text, ok := element.(string); ok {
+				for _, key := range keys {
+					if text == key {
+						remove = true
+						break
+					}
+				}
+			}
+			if !remove {
+				out = append(out, element)
+			}
+		}
+		return out
+	default:
+		return node
+	}
+}
+
+func jsonbDeleteIndex(array []interface{}, index int) []interface{} {
+	if index < 0 {
+		index += len(array)
+	}
+	if index < 0 || index >= len(array) {
+		return array
+	}
+	out := make([]interface{}, 0, len(array)-1)
+	out = append(out, array[:index]...)
+	out = append(out, array[index+1:]...)
+	return out
+}
+
+// jsonbConcatNodes implements jsonb || jsonb: objects merge right-wins, arrays
+// concatenate, and any non-array operand becomes a single-element array.
+func jsonbConcatNodes(left, right interface{}) interface{} {
+	leftObject, leftIsObject := left.(map[string]interface{})
+	rightObject, rightIsObject := right.(map[string]interface{})
+	if leftIsObject && rightIsObject {
+		out := make(map[string]interface{}, len(leftObject)+len(rightObject))
+		for key, value := range leftObject {
+			out[key] = value
+		}
+		for key, value := range rightObject {
+			out[key] = value
+		}
+		return out
+	}
+	asArray := func(node interface{}) []interface{} {
+		if array, ok := node.([]interface{}); ok {
+			return array
+		}
+		return []interface{}{node}
+	}
+	out := append([]interface{}{}, asArray(left)...)
+	return append(out, asArray(right)...)
+}
+
+// jsonbContains implements jsonb containment: object keys/values recurse, array
+// elements are order-independent, top-level arrays contain scalars, and scalars
+// compare by type.
+func jsonbContains(container, candidate interface{}) bool {
+	// A top-level array contains a scalar candidate when any element does.
+	switch candidate.(type) {
+	case nil, bool, string, json.Number:
+		if containerArray, ok := container.([]interface{}); ok {
+			for _, element := range containerArray {
+				if jsonbContains(element, candidate) {
+					return true
+				}
+			}
+			return false
+		}
+	}
+
+	switch candidateValue := candidate.(type) {
+	case map[string]interface{}:
+		containerObject, ok := container.(map[string]interface{})
+		if !ok {
+			return false
+		}
+		for key, value := range candidateValue {
+			other, exists := containerObject[key]
+			if !exists || !jsonbContains(other, value) {
+				return false
+			}
+		}
+		return true
+	case []interface{}:
+		containerArray, ok := container.([]interface{})
+		if ok {
+			// PostgreSQL array containment does not require multiplicity: each
+			// candidate element only needs some container element, and duplicates
+			// may match the same element.
+			for _, candidateElement := range candidateValue {
+				matched := false
+				for _, containerElement := range containerArray {
+					if jsonbContains(containerElement, candidateElement) {
+						matched = true
+						break
+					}
+				}
+				if !matched {
+					return false
+				}
+			}
+			return true
+		}
+		// A non-array container can still contain a nested empty array? No.
+		return false
+	case nil:
+		return container == nil
+	case bool:
+		other, ok := container.(bool)
+		return ok && other == candidateValue
+	case string:
+		other, ok := container.(string)
+		return ok && other == candidateValue
+	case json.Number:
+		candidateText, err := canonicalJSONBNumber(candidateValue)
+		if err != nil {
+			return false
+		}
+		other, ok := container.(json.Number)
+		if !ok {
+			return false
+		}
+		containerText, err := canonicalJSONBNumber(other)
+		if err != nil {
+			return false
+		}
+		return containerText == candidateText
+	}
+	return false
+}
+
+// jsonbNodeTypeName returns the PostgreSQL jsonb type name.
+func jsonbNodeTypeName(node interface{}) string {
+	switch node.(type) {
+	case nil:
+		return "null"
+	case bool:
+		return "boolean"
+	case json.Number:
+		return "number"
+	case string:
+		return "string"
+	case []interface{}:
+		return "array"
+	case map[string]interface{}:
+		return "object"
+	default:
+		return "unknown"
+	}
+}
+
+// jsonbPathArg resolves a path operand to path steps. A text[] value (canonical
+// array text) is PostgreSQL's path form; a $string is the JSON1 path form this
+// engine already uses for -> and json_extract.
+func jsonbPathArg(value interface{}) ([]jsonPathSeg, error) {
+	text := strings.TrimSpace(toString(value))
+	switch {
+	case strings.HasPrefix(text, "{"):
+		return parseJSONBArrayPath(text)
+	case strings.HasPrefix(text, "$"):
+		return parseJSONBJSONPath(text)
+	default:
+		return nil, pgerror.InvalidParameter("jsonb path must be a text[] array literal such as '{a,0}' or a $ path, got %q", text)
+	}
+}
+
+// jsonbKeyListArg parses a text[] of object keys. Unlike a path, every element
+// is a key: a numeric-looking key such as "1" must not become an array index.
+func jsonbKeyListArg(value interface{}) ([]string, error) {
+	text := strings.TrimSpace(toString(value))
+	if !strings.HasPrefix(text, "{") {
+		return nil, pgerror.InvalidParameter("jsonb key list must be a text[] array literal, got %q", text)
+	}
+	elements, err := parseArrayLiteral(text)
+	if err != nil {
+		return nil, err
+	}
+	keys := make([]string, 0, len(elements))
+	for _, element := range elements {
+		if element == nil {
+			return nil, pgerror.InvalidParameter("jsonb key list element must not be NULL")
+		}
+		keys = append(keys, *element)
+	}
+	return keys, nil
+}
+
+// parseJSONBArrayPath parses PostgreSQL text[] path text into steps. A path
+// 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 {
+		return nil, err
+	}
+	segs := make([]jsonPathSeg, 0, len(elements))
+	for _, element := range elements {
+		if element == nil {
+			return nil, pgerror.InvalidParameter("jsonb path element must not be NULL")
+		}
+		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.
+func parseJSONBJSONPath(path string) ([]jsonPathSeg, error) {
+	if unsupportedJSONPathShape(path) {
+		return nil, pgerror.Unsupported("jsonpath wildcards, slices, and unbounded expansion are not supported; pass an explicit path")
+	}
+	segs, err := parseJSONPath(path)
+	if err != nil {
+		return nil, err
+	}
+	for _, segment := range segs {
+		if !segment.isIndex && (segment.key == "*" || strings.HasPrefix(segment.key, "**")) {
+			return nil, pgerror.Unsupported("jsonpath wildcards and unbounded expansion are not supported; pass an explicit path")
+		}
+	}
+	return segs, nil
+}
+
+// unsupportedJSONPathShape reports whether path text contains a jsonpath
+// wildcard, slice, or deep-expansion marker inside brackets or as a bare member.
+func unsupportedJSONPathShape(path string) bool {
+	for i := 0; i < len(path); i++ {
+		if path[i] != '[' {
+			continue
+		}
+		end := strings.IndexByte(path[i:], ']')
+		if end < 0 {
+			return false
+		}
+		inner := path[i+1 : i+end]
+		if strings.ContainsAny(inner, "*:") {
+			return true
+		}
+		i += end
+	}
+	return false
+}
+
+// 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 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")
+	}
+	if len(segs) > 1 {
+		child, exists, err := jsonbPathChild(node, segment)
+		if err != nil {
+			return nil, err
+		}
+		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
+		}
+		return jsonbReplaceChild(node, segment, updated)
+	}
+
+	switch container := node.(type) {
+	case map[string]interface{}:
+		if segment.isIndex {
+			return nil, pgerror.InvalidText("path element is not an object key")
+		}
+		if _, exists := container[segment.key]; !exists && !create {
+			return node, nil
+		}
+		out := make(map[string]interface{}, len(container)+1)
+		for key, item := range container {
+			out[key] = item
+		}
+		out[segment.key] = value
+		return out, nil
+	case []interface{}:
+		if !segment.isIndex {
+			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 {
+			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 !create {
+				return node, nil
+			}
+			out := append([]interface{}{}, container...)
+			return append(out, value), nil
+		}
+		out := append([]interface{}{}, container...)
+		out[index] = value
+		return out, nil
+	default:
+		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 (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, 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 {
+			return nil, err
+		}
+		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
+		}
+		return jsonbReplaceChild(node, segment, updated)
+	}
+
+	switch container := node.(type) {
+	case map[string]interface{}:
+		if segment.isIndex {
+			return nil, pgerror.InvalidText("path element is not an object key")
+		}
+		if _, exists := container[segment.key]; exists {
+			return nil, pgerror.InvalidParameter("cannot replace existing key")
+		}
+		out := make(map[string]interface{}, len(container)+1)
+		for key, item := range container {
+			out[key] = item
+		}
+		out[segment.key] = value
+		return out, nil
+	case []interface{}:
+		if !segment.isIndex {
+			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 = 0
+		}
+		if index > len(container) {
+			index = len(container)
+		}
+		if after {
+			index++
+		}
+		if index > len(container) {
+			index = len(container)
+		}
+		out := make([]interface{}, 0, len(container)+1)
+		out = append(out, container[:index]...)
+		out = append(out, value)
+		out = append(out, container[index:]...)
+		return out, nil
+	default:
+		return nil, pgerror.InvalidParameter("cannot set path in scalar")
+	}
+}
+
+// jsonbPathChild resolves one non-final path step to an existing child.
+func jsonbPathChild(node interface{}, segment jsonPathSeg) (interface{}, bool, error) {
+	switch container := node.(type) {
+	case map[string]interface{}:
+		if segment.isIndex {
+			return nil, false, pgerror.InvalidParameter("jsonb path element %d does not identify an object key", segment.index)
+		}
+		child, exists := container[segment.key]
+		return child, exists, nil
+	case []interface{}:
+		if !segment.isIndex {
+			return nil, false, pgerror.InvalidParameter("jsonb path element %q does not identify an array index", segment.key)
+		}
+		index := segment.index
+		if index < 0 {
+			index += len(container)
+		}
+		if index < 0 || index >= len(container) {
+			return nil, false, nil
+		}
+		return container[index], true, nil
+	default:
+		return nil, false, pgerror.InvalidParameter("cannot walk a jsonb path through a %s", jsonbNodeTypeName(node))
+	}
+}
+
+// jsonbReplaceChild writes an updated child back into its container.
+func jsonbReplaceChild(node interface{}, segment jsonPathSeg, child interface{}) (interface{}, error) {
+	switch container := node.(type) {
+	case map[string]interface{}:
+		if segment.isIndex {
+			return nil, pgerror.InvalidParameter("jsonb path element %d does not identify an object key", segment.index)
+		}
+		out := make(map[string]interface{}, len(container)+1)
+		for key, item := range container {
+			out[key] = item
+		}
+		out[segment.key] = child
+		return out, nil
+	case []interface{}:
+		if !segment.isIndex {
+			return nil, pgerror.InvalidParameter("jsonb path element %q does not identify an array index", segment.key)
+		}
+		index := segment.index
+		if index < 0 {
+			index += len(container)
+		}
+		if index < 0 || index >= len(container) {
+			return nil, pgerror.InvalidParameter("jsonb path array index %d is out of range", segment.index)
+		}
+		out := append([]interface{}{}, container...)
+		out[index] = child
+		return out, nil
+	default:
+		return nil, pgerror.InvalidParameter("cannot write a jsonb path through a %s", jsonbNodeTypeName(node))
+	}
+}
+
+// jsonbStripNulls removes object members whose value is JSON null. Array
+// elements are kept, matching jsonb_strip_nulls.
+func jsonbStripNulls(node interface{}) interface{} {
+	switch value := node.(type) {
+	case map[string]interface{}:
+		out := make(map[string]interface{}, len(value))
+		for key, item := range value {
+			if item == nil {
+				continue
+			}
+			out[key] = jsonbStripNulls(item)
+		}
+		return out
+	case []interface{}:
+		out := make([]interface{}, len(value))
+		for i, item := range value {
+			out[i] = jsonbStripNulls(item)
+		}
+		return out
+	default:
+		return node
+	}
+}
+
+// jsonbEncodeNode canonicalizes a tree that has already been validated.
+func jsonbEncodeNode(node interface{}) (string, error) {
+	return encodeJSONB(node, false)
+}
+
+// evalJSONBFunction evaluates a native jsonb scalar function. handled is false
+// for names it does not own, keeping the JSON1 surface untouched.
+func (e *Executor) evalJSONBFunction(name string, fn *parser.FunctionCall, args []interface{}) (interface{}, bool, error) {
+	upper := strings.ToUpper(name)
+	switch upper {
+	case "TO_JSONB":
+		if len(args) != 1 {
+			return nil, true, pgerror.InvalidParameter("to_jsonb() requires exactly one argument")
+		}
+		if args[0] == nil {
+			return nil, true, nil
+		}
+		node, err := e.toJSONBNode(firstFunctionArg(fn), args[0])
+		if err != nil {
+			return nil, true, err
+		}
+		text, err := jsonbEncodeNode(node)
+		return text, true, err
+
+	case "JSONB_TYPEOF":
+		if len(args) != 1 {
+			return nil, true, pgerror.InvalidParameter("jsonb_typeof() requires exactly one argument")
+		}
+		node, err := jsonbNodeFromArg(args[0])
+		if err != nil {
+			return nil, true, err
+		}
+		if node == nil && args[0] == nil {
+			return nil, true, nil
+		}
+		return jsonbNodeTypeName(node), true, nil
+
+	case "JSONB_ARRAY_LENGTH":
+		if len(args) != 1 {
+			return nil, true, pgerror.InvalidParameter("jsonb_array_length() requires exactly one argument")
+		}
+		node, err := jsonbNodeFromArg(args[0])
+		if err != nil {
+			return nil, true, err
+		}
+		if node == nil && args[0] == nil {
+			return nil, true, nil
+		}
+		array, ok := node.([]interface{})
+		if !ok {
+			return nil, true, pgerror.InvalidParameter("cannot get array length of a jsonb %s", jsonbNodeTypeName(node))
+		}
+		return int64(len(array)), true, nil
+
+	case "JSONB_PRETTY":
+		if len(args) != 1 {
+			return nil, true, pgerror.InvalidParameter("jsonb_pretty() requires exactly one argument")
+		}
+		if args[0] == nil {
+			return nil, true, nil
+		}
+		node, err := jsonbNodeFromArg(args[0])
+		if err != nil {
+			return nil, true, err
+		}
+		text, err := encodeJSONB(node, true)
+		return text, true, err
+
+	case "JSONB_STRIP_NULLS":
+		if len(args) != 1 {
+			return nil, true, pgerror.InvalidParameter("jsonb_strip_nulls() requires exactly one argument")
+		}
+		if args[0] == nil {
+			return nil, true, nil
+		}
+		node, err := jsonbNodeFromArg(args[0])
+		if err != nil {
+			return nil, true, err
+		}
+		text, err := jsonbEncodeNode(jsonbStripNulls(node))
+		return text, true, err
+
+	case "JSONB_BUILD_ARRAY":
+		values := make([]interface{}, len(args))
+		for i, arg := range args {
+			node, err := e.toJSONBNode(functionArgAt(fn, i), arg)
+			if err != nil {
+				return nil, true, err
+			}
+			values[i] = node
+		}
+		text, err := jsonbEncodeNode(values)
+		return text, true, err
+
+	case "JSONB_BUILD_OBJECT":
+		if len(args)%2 != 0 {
+			return nil, true, pgerror.InvalidParameter("jsonb_build_object() argument list must have an even number of elements")
+		}
+		object := make(map[string]interface{}, len(args)/2)
+		for i := 0; i+1 < len(args); i += 2 {
+			if args[i] == nil {
+				return nil, true, pgerror.InvalidParameter("jsonb_build_object() argument %d: key must not be null", i+1)
+			}
+			key := toString(args[i])
+			node, err := e.toJSONBNode(functionArgAt(fn, i+1), args[i+1])
+			if err != nil {
+				return nil, true, err
+			}
+			object[key] = node
+		}
+		text, err := jsonbEncodeNode(object)
+		return text, true, err
+
+	case "JSONB_SET", "JSONB_INSERT", "JSONB_REPLACE", "JSONB_REMOVE":
+		value, err := e.evalJSONBPatch(upper, fn, args)
+		return value, true, err
+
+	case "JSONB_EACH", "JSONB_EACH_TEXT", "JSONB_ARRAY_ELEMENTS", "JSONB_ARRAY_ELEMENTS_TEXT", "JSONB_OBJECT_KEYS":
+		return nil, true, pgerror.Unsupported("%s() is a set-returning function and is only supported as the single FROM item, as in SELECT * FROM %s(jsonb_column)", name, strings.ToLower(name))
+
+	case "JSONB_PATH_EXISTS", "JSONB_PATH_EXISTS_TZ", "JSONB_PATH_MATCH", "JSONB_PATH_MATCH_TZ",
+		"JSONB_PATH_QUERY", "JSONB_PATH_QUERY_TZ", "JSONB_PATH_QUERY_ARRAY", "JSONB_PATH_QUERY_ARRAY_TZ",
+		"JSONB_PATH_QUERY_FIRST", "JSONB_PATH_QUERY_FIRST_TZ":
+		return nil, true, pgerror.Unsupported("jsonpath (%s) is not supported", strings.ToLower(name))
+	}
+	return nil, false, nil
+}
+
+// firstFunctionArg returns the first argument expression of a call, or nil when
+// the call has no arguments.
+func firstFunctionArg(fn *parser.FunctionCall) parser.Expr {
+	if fn == nil || len(fn.Args) == 0 {
+		return nil
+	}
+	return fn.Args[0]
+}
+
+func functionArgAt(fn *parser.FunctionCall, index int) parser.Expr {
+	if fn == nil || index < 0 || index >= len(fn.Args) {
+		return nil
+	}
+	return fn.Args[index]
+}
+
+// toJSONBNode converts a SQL value to a JSON tree node, decoding arrays into
+// JSON arrays so to_jsonb(jsonb_build_array(...)) and to_jsonb(array_column)
+// keep their element types.
+func (e *Executor) toJSONBNode(expr parser.Expr, value interface{}) (interface{}, error) {
+	if value == nil {
+		return nil, nil
+	}
+	// to_jsonb(jsonb) is the identity: a jsonb argument is embedded as its
+	// document, not as a JSON string.
+	if e.isJSONBExpr(expr) {
+		return jsonbNodeFromArg(value)
+	}
+	if typeName, ok := e.arrayTypeOf(expr); ok {
+		elements, err := parseArrayLiteral(toString(value))
+		if err != nil {
+			return nil, err
+		}
+		elementType, _ := arrayElementType(typeName)
+		nodes := make([]interface{}, len(elements))
+		for i, element := range elements {
+			typed, valueErr := arrayElementValue(element, elementType)
+			if valueErr != nil {
+				return nil, valueErr
+			}
+			node, nodeErr := jsonbNodeFromSQLValue(typed)
+			if nodeErr != nil {
+				return nil, nodeErr
+			}
+			nodes[i] = node
+		}
+		return nodes, nil
+	}
+	return jsonbNodeFromSQLValue(value)
+}
+
+// jsonbPatchNode converts a JSON1 $ path patch value to a JSON tree node. A
+// float64 must become a json.Number before it reaches the encoder, and NaN and
+// infinity are rejected instead of producing invalid JSON.
+func jsonbPatchNode(value interface{}) (interface{}, error) {
+	switch typed := value.(type) {
+	case jsonText:
+		if node, err := jsonDecode(string(typed)); err == nil {
+			return node, nil
+		}
+		return string(typed), nil
+	case nil, bool, int, int8, int16, int32, int64,
+		uint, uint32, uint64, float32, float64, string, []byte:
+		return jsonbNodeFromSQLValue(value)
+	default:
+		return jsonArgToNode(value), nil
+	}
+}
+
+// evalJSONBPatch evaluates the jsonb_set/jsonb_insert/jsonb_replace/
+// jsonb_remove family. A $ path keeps the JSON1 semantics the engine already
+// exposed; a PostgreSQL text[] path uses PostgreSQL's jsonb_set/jsonb_insert
+// rules and accepts the optional boolean fourth argument.
+func (e *Executor) evalJSONBPatch(name string, fn *parser.FunctionCall, args []interface{}) (interface{}, error) {
+	minArgs := 2
+	if name != "JSONB_REMOVE" {
+		minArgs = 3
+	}
+	if len(args) < minArgs {
+		return nil, pgerror.InvalidParameter("%s() requires at least %d arguments", strings.ToLower(name), minArgs)
+	}
+	if args[0] == nil || args[1] == nil {
+		return nil, nil
+	}
+	node, err := jsonbNodeFromArg(args[0])
+	if err != nil {
+		return nil, err
+	}
+
+	if name == "JSONB_REMOVE" {
+		for _, path := range args[1:] {
+			if path == nil {
+				return nil, nil
+			}
+			segs, pathErr := jsonbPathArg(path)
+			if pathErr != nil {
+				return nil, pathErr
+			}
+			node, err = jsonRemove(node, segs)
+			if err != nil {
+				return nil, err
+			}
+		}
+		return jsonbEncodeNode(node)
+	}
+
+	// JSON1 multi-path form: jsonb_set(doc, path, value, path, value, ...).
+	// PostgreSQL's form has exactly three or four arguments.
+	pathText := strings.TrimSpace(toString(args[1]))
+	pgPath := strings.HasPrefix(pathText, "{")
+	if pgPath && len(args) > 4 {
+		return nil, pgerror.InvalidParameter("%s() with a PostgreSQL path accepts at most four arguments", strings.ToLower(name))
+	}
+	if len(args) == 4 && !pgPath {
+		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))
+	}
+
+	flag := false
+	if len(args) == 4 {
+		if args[3] == nil {
+			return nil, nil
+		}
+		flag = toBool(args[3])
+	}
+
+	if !pgPath {
+		// 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":
+			mode = "insert"
+		case "JSONB_REPLACE":
+			mode = "replace"
+		}
+		for i := 1; i+1 < len(args); i += 2 {
+			if args[i] == nil || args[i+1] == nil {
+				return nil, nil
+			}
+			segs, pathErr := jsonbPathArg(args[i])
+			if pathErr != nil {
+				return nil, pathErr
+			}
+			var patchValue interface{}
+			if e.isJSONBExpr(functionArgAt(fn, i+1)) {
+				patchValue, err = jsonbNodeFromArg(args[i+1])
+			} else {
+				patchValue, err = jsonbPatchNode(args[i+1])
+			}
+			if err != nil {
+				return nil, err
+			}
+			node, err = jsonApplyPatch(node, segs, patchValue, mode)
+			if err != nil {
+				return nil, err
+			}
+		}
+		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
+	}
+	switch name {
+	case "JSONB_SET":
+		create := true
+		if len(args) == 4 {
+			create = flag
+		}
+		node, err = jsonbSetPG(node, segs, newValueNode, create)
+	case "JSONB_INSERT":
+		node, err = jsonbInsertPG(node, segs, newValueNode, flag)
+	case "JSONB_REPLACE":
+		return nil, pgerror.Unsupported("jsonb_replace() with a PostgreSQL path is not supported; use jsonb_set or the JSON1 $ path form")
+	}
+	if err != nil {
+		return nil, err
+	}
+	return jsonbEncodeNode(node)
+}
+
+// jsonbTableFunction materializes a set-returning jsonb function as a relation.
+// The returned types are the SQL type names used for result metadata.
+func jsonbTableFunction(name string, value interface{}) (columns, types []string, rows [][]interface{}, err error) {
+	node, err := jsonbNodeFromArg(value)
+	if err != nil {
+		return nil, nil, nil, err
+	}
+	if value == nil {
+		switch strings.ToLower(name) {
+		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
+		}
+	}
+
+	switch strings.ToLower(name) {
+	case "jsonb_each", "jsonb_each_text", "jsonb_object_keys":
+		object, ok := node.(map[string]interface{})
+		if !ok {
+			return nil, nil, nil, pgerror.InvalidParameter("cannot call %s() on a jsonb %s", strings.ToLower(name), jsonbNodeTypeName(node))
+		}
+		keys := make([]string, 0, len(object))
+		for key := range object {
+			keys = append(keys, key)
+		}
+		sort.Strings(keys)
+		if strings.EqualFold(name, "jsonb_object_keys") {
+			rows = make([][]interface{}, 0, len(keys))
+			for _, key := range keys {
+				rows = append(rows, []interface{}{key})
+			}
+			return []string{"jsonb_object_keys"}, []string{"TEXT"}, rows, nil
+		}
+		textValues := strings.EqualFold(name, "jsonb_each_text")
+		valueType := "JSONB"
+		if textValues {
+			valueType = "TEXT"
+		}
+		rows = make([][]interface{}, 0, len(keys))
+		for _, key := range keys {
+			var rendered interface{}
+			if textValues {
+				rendered, err = jsonbNodeToText(object[key])
+			} else {
+				rendered, err = encodeJSONB(object[key], false)
+			}
+			if err != nil {
+				return nil, nil, nil, err
+			}
+			rows = append(rows, []interface{}{key, rendered})
+		}
+		return []string{"key", "value"}, []string{"TEXT", valueType}, rows, nil
+
+	case "jsonb_array_elements", "jsonb_array_elements_text":
+		array, ok := node.([]interface{})
+		if !ok {
+			return nil, nil, nil, pgerror.InvalidParameter("cannot call %s() on a jsonb %s", strings.ToLower(name), jsonbNodeTypeName(node))
+		}
+		textValues := strings.EqualFold(name, "jsonb_array_elements_text")
+		valueType := "JSONB"
+		if textValues {
+			valueType = "TEXT"
+		}
+		rows = make([][]interface{}, 0, len(array))
+		for _, element := range array {
+			var rendered interface{}
+			if textValues {
+				rendered, err = jsonbNodeToText(element)
+			} else {
+				rendered, err = encodeJSONB(element, false)
+			}
+			if err != nil {
+				return nil, nil, nil, err
+			}
+			rows = append(rows, []interface{}{rendered})
+		}
+		return []string{"value"}, []string{valueType}, rows, nil
+	}
+	return nil, nil, nil, pgerror.Unsupported("table function %s() is not supported", name)
+}
+
+// normalizeJSONBRow canonicalizes every JSONB-typed column value before it is
+// written, mirroring normalizeArrayRow. A value is canonicalized once, so a
+// reload reads back the same bytes.
+func (e *Executor) normalizeJSONBRow(schema *storage.Schema, row storage.Row) error {
+	if schema == nil {
+		return nil
+	}
+	for _, column := range schema.Columns {
+		if !strings.EqualFold(strings.TrimSpace(column.Type), "JSONB") {
+			continue
+		}
+		value, ok := lookupRowValue(row, column.Name)
+		if !ok || value == nil {
+			continue
+		}
+		text, isText := value.(string)
+		if !isText {
+			if jsonValue, isJSON := value.(jsonText); isJSON {
+				text = string(jsonValue)
+			} else {
+				return pgerror.InvalidText("column %q is %s and accepts JSON text, got %T", column.Name, column.Type, value)
+			}
+		}
+		canonical, err := CanonicalizeJSONB(text)
+		if err != nil {
+			return fmt.Errorf("column %q: %w", column.Name, err)
+		}
+		row[column.Name] = canonical
+	}
+	return nil
+}
+
+// canonicalJSONBDefault canonicalizes a constant DEFAULT for a JSONB column so
+// the catalog and any ALTER TABLE backfill store the same bytes an INSERT would.
+func canonicalJSONBDefault(typeName string, value interface{}, exprText string) (interface{}, string, error) {
+	if !strings.EqualFold(strings.TrimSpace(typeName), "JSONB") || exprText != "" || value == nil {
+		return value, exprText, nil
+	}
+	text, ok := value.(string)
+	if !ok {
+		if jsonValue, isJSON := value.(jsonText); isJSON {
+			text = string(jsonValue)
+		} else {
+			return nil, "", pgerror.InvalidText("DEFAULT for %s must be JSON text, got %T", typeName, value)
+		}
+	}
+	canonical, err := CanonicalizeJSONB(text)
+	if err != nil {
+		return nil, "", err
+	}
+	return canonical, "", nil
+}

+ 964 - 0
pkg/executor/jsonb_test.go

@@ -0,0 +1,964 @@
+package executor
+
+import (
+	"encoding/json"
+	"errors"
+	"strings"
+	"testing"
+
+	"github.com/danfragoso/pizzasql-next/pkg/analyzer"
+	"github.com/danfragoso/pizzasql-next/pkg/pgerror"
+	"github.com/danfragoso/pizzasql-next/pkg/storage"
+)
+
+// jsonbExec parses and executes one statement, returning the value at (0, 0).
+func jsonbValue(t *testing.T, e *Executor, sql string) interface{} {
+	t.Helper()
+	res := execMust(t, e, sql)
+	if len(res.Rows) == 0 || len(res.Rows[0]) == 0 {
+		t.Fatalf("%s: no value", sql)
+	}
+	return res.Rows[0][0]
+}
+
+func jsonbErrorCode(t *testing.T, err error) string {
+	t.Helper()
+	switch {
+	case err == nil:
+		return ""
+	case errors.Is(err, pgerror.ErrInvalidText):
+		return pgerror.CodeInvalidText
+	case errors.Is(err, pgerror.ErrInvalidParameter):
+		return pgerror.CodeInvalidParameter
+	case errors.Is(err, pgerror.ErrFeatureNotSupported):
+		return pgerror.CodeFeatureNotSupported
+	case errors.Is(err, pgerror.ErrDatatypeMismatch):
+		return pgerror.CodeDatatypeMismatch
+	}
+	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) {
+	pool, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE docs (id INTEGER PRIMARY KEY, doc JSONB)")
+	execMust(t, e, `INSERT INTO docs VALUES (1, '{"b": 1, "a": 2, "b": 3}')`)
+	execMust(t, e, `INSERT INTO docs VALUES (2, '{"z": 1.50, "y": 1e2, "x": -0.0, "w": 1.0}')`)
+	execMust(t, e, `INSERT INTO docs VALUES (3, ' [ 1 , { "b" : 2 , "a" : 1 } ] ')`)
+
+	want := map[int64]string{
+		1: `{"a":2,"b":3}`,
+		2: `{"w":1,"x":0,"y":100,"z":1.5}`,
+		3: `[1,{"a":1,"b":2}]`,
+	}
+	res := execMust(t, e, "SELECT id, doc FROM docs ORDER BY id")
+	for _, row := range res.Rows {
+		id := row[0].(int64)
+		if row[1] != want[id] {
+			t.Fatalf("row %d doc = %v, want %s", id, row[1], want[id])
+		}
+	}
+
+	// A JSONB column rejects malformed documents and stores nothing.
+	if _, err := execSQL(e, `INSERT INTO docs VALUES (4, 'not json')`); jsonbErrorCode(t, err) != pgerror.CodeInvalidText {
+		t.Fatalf("malformed JSONB insert error = %v", err)
+	}
+	if res := execMust(t, e, "SELECT COUNT(*) FROM docs WHERE id = 4"); res.Rows[0][0] != int64(0) {
+		t.Fatalf("malformed JSONB insert stored a row")
+	}
+
+	// Non-text input is not silently stringified, matching array columns.
+	if _, err := execSQL(e, `INSERT INTO docs VALUES (5, 5)`); jsonbErrorCode(t, err) != pgerror.CodeInvalidText {
+		t.Fatalf("integer JSONB insert error = %v", err)
+	}
+
+	// Canonicalization is idempotent, and a fresh executor over the same store
+	// reads back the same bytes (no hidden normalization at read time).
+	reloadedSchema := storage.NewSchemaManager(pool, "testdb")
+	reloadedTable := storage.NewTableManager(pool, reloadedSchema, "testdb")
+	reloaded := newExec(reloadedSchema, reloadedTable)
+	res = execMust(t, reloaded, "SELECT id, doc FROM docs ORDER BY id")
+	for _, row := range res.Rows {
+		id := row[0].(int64)
+		if row[1] != want[id] {
+			t.Fatalf("reloaded row %d doc = %v, want %s", id, row[1], want[id])
+		}
+		canonical, err := CanonicalizeJSONB(row[1].(string))
+		if err != nil || canonical != row[1] {
+			t.Fatalf("row %d is not a canonical fixed point: %q (%v)", id, row[1], err)
+		}
+	}
+}
+
+func TestJSONBCastsAndToJSONB(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+
+	cases := []struct {
+		sql  string
+		want interface{}
+	}{
+		{`SELECT '{"b":2,"a":1}'::jsonb`, `{"a":1,"b":2}`},
+		{`SELECT CAST(' [1, 2] ' AS JSONB)`, `[1,2]`},
+		{`SELECT ('{"a":1}'::jsonb)::jsonb`, `{"a":1}`},
+		{`SELECT to_jsonb(5)`, `5`},
+		{`SELECT to_jsonb('x')`, `"x"`},
+		{`SELECT to_jsonb(TRUE)`, `true`},
+		{`SELECT to_jsonb(NULL)`, nil},
+		{`SELECT to_jsonb(ARRAY[1,2])`, `[1,2]`},
+		{`SELECT to_jsonb(ARRAY['a','b'])`, `["a","b"]`},
+		{`SELECT to_jsonb('{"a":1}'::jsonb)`, `{"a":1}`},
+		{`SELECT CAST('{"a":1}'::jsonb AS TEXT)`, `{"a":1}`},
+	}
+	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)
+		}
+	}
+
+	for _, sql := range []string{
+		`SELECT 'not json'::jsonb`,
+		`SELECT '{"a":}'::jsonb`,
+		`SELECT '{"a":1} trailing'::jsonb`,
+		`SELECT 5::jsonb`,
+		`SELECT '{"a":1e999999}'::jsonb`,
+	} {
+		if _, err := execSQL(e, sql); jsonbErrorCode(t, err) != pgerror.CodeInvalidText {
+			t.Errorf("%s error = %v, want 22P02", sql, err)
+		}
+	}
+
+	// The JSON1 normalization function and its json() sibling keep working.
+	if got := jsonbValue(t, e, `SELECT jsonb('{"a":1}')`); got != `{"a":1}` {
+		t.Fatalf("jsonb() = %v", got)
+	}
+	if got := jsonbValue(t, e, `SELECT jsonb_valid('{"a":1}')`); got != int64(1) {
+		t.Fatalf("jsonb_valid() = %v", got)
+	}
+}
+
+func TestJSONBArrowOperators(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE docs (id INTEGER PRIMARY KEY, doc JSONB)")
+	execMust(t, e, `INSERT INTO docs VALUES (1, '{"a":1,"s":"x","n":null,"arr":[10,{"b":2}]}')`)
+
+	cases := []struct {
+		sql  string
+		want interface{}
+	}{
+		{`SELECT doc -> 'a' FROM docs`, `1`},
+		{`SELECT doc -> 's' FROM docs`, `"x"`},
+		{`SELECT doc ->> 's' FROM docs`, `x`},
+		{`SELECT doc -> 'n' FROM docs`, `null`},
+		{`SELECT doc ->> 'n' FROM docs`, nil},
+		{`SELECT doc -> 'missing' FROM docs`, nil},
+		{`SELECT doc ->> 'missing' FROM docs`, nil},
+		{`SELECT doc -> 'arr' -> 0 FROM docs`, `10`},
+		{`SELECT doc -> 'arr' -> -1 ->> 'b' FROM docs`, `2`},
+		{`SELECT doc -> 'arr' -> 3 FROM docs`, nil},
+		{`SELECT doc -> '$."arr"[1]' ->> 'b' FROM docs`, `2`},
+		{`SELECT NULL::jsonb -> 'a'`, nil},
+		{`SELECT doc -> NULL FROM docs`, 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)
+		}
+	}
+
+	// The JSON text surface is unchanged: a text-backed document still returns
+	// JSON1 coercions for ->>.
+	if got := jsonbValue(t, e, `SELECT '{"a":true}' ->> 'a'`); got != int64(1) {
+		t.Fatalf("JSON1 ->> boolean = %v (%T), want int64 1", got, got)
+	}
+}
+
+func TestJSONBPathOperators(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+
+	cases := []struct {
+		sql  string
+		want interface{}
+	}{
+		{`SELECT '{"a":{"b":2}}'::jsonb #> '{a,b}'`, `2`},
+		{`SELECT '{"a":{"b":2}}'::jsonb #>> '{a,b}'`, `2`},
+		{`SELECT '{"a":[1,2,3]}'::jsonb #> '{a,-1}'`, `3`},
+		{`SELECT '{"a":{"b":2}}'::jsonb #> '$."a"."b"'`, `2`},
+		{`SELECT '{"a":{"b":2}}'::jsonb #> ARRAY['a','b']`, `2`},
+		{`SELECT '{"a":1}'::jsonb #> '{missing}'`, nil},
+		{`SELECT '{"a":1}'::jsonb #- '{a}'`, `{}`},
+		{`SELECT '{"a":{"b":1},"c":2}'::jsonb #- '{a,b}'`, `{"a":{},"c":2}`},
+		{`SELECT '[1,2,3]'::jsonb #- '{1}'`, `[1,3]`},
+		{`SELECT '{"a":1}'::jsonb #- '{missing}'`, `{"a":1}`},
+		{`SELECT NULL::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)
+		}
+	}
+
+	// Wildcards, slices, and unbounded expansion are rejected, not approximated.
+	for _, sql := range []string{
+		`SELECT '{"a":1}'::jsonb #> '$[*]'`,
+		`SELECT '{"a":1}'::jsonb #> '$.**'`,
+		`SELECT '{"a":1}'::jsonb -> '$[*]'`,
+		`SELECT '{"a":1}'::jsonb #- '$[0:1]'`,
+	} {
+		if _, err := execSQL(e, sql); jsonbErrorCode(t, err) != pgerror.CodeFeatureNotSupported {
+			t.Errorf("%s error = %v, want 0A000", sql, err)
+		}
+	}
+}
+
+func TestJSONBContainmentAndExistence(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+
+	cases := []struct {
+		sql  string
+		want interface{}
+	}{
+		{`SELECT '{"a":1,"b":{"c":2}}'::jsonb @> '{"a":1}'::jsonb`, true},
+		{`SELECT '{"a":1,"b":{"c":2}}'::jsonb @> '{"b":{"c":2}}'::jsonb`, true},
+		{`SELECT '{"a":1,"b":{"c":2}}'::jsonb @> '{"b":{}}'::jsonb`, true},
+		{`SELECT '{"a":1}'::jsonb @> '{"b":1}'::jsonb`, false},
+		{`SELECT '{"a":1}'::jsonb @> '{"a":1,"b":2}'::jsonb`, false},
+		{`SELECT '[1,2,3]'::jsonb @> '[3,1]'::jsonb`, true},
+		{`SELECT '[1,2,3]'::jsonb @> '[4]'::jsonb`, false},
+		{`SELECT '[1,2]'::jsonb @> '1'::jsonb`, true},
+		{`SELECT '["a"]'::jsonb @> '"a"'::jsonb`, true},
+		{`SELECT '[true]'::jsonb @> 'true'::jsonb`, true},
+		{`SELECT '[null]'::jsonb @> 'null'::jsonb`, true},
+		{`SELECT '[1]'::jsonb @> '2'::jsonb`, false},
+		{`SELECT '{"a":[1,2]}'::jsonb @> '{"a":[2]}'::jsonb`, true},
+		{`SELECT '{"a":null}'::jsonb @> '{"a":null}'::jsonb`, true},
+		{`SELECT '{"a":null}'::jsonb @> '{"a":1}'::jsonb`, false},
+		{`SELECT '{"a":1.0}'::jsonb @> '{"a":1}'::jsonb`, true},
+		{`SELECT '{"a":1}'::jsonb <@ '{"a":1,"b":2}'::jsonb`, true},
+		{`SELECT '{"a":1}'::jsonb @> '{"a":1}'`, true},
+		{`SELECT NULL::jsonb @> '{}'::jsonb`, nil},
+		{`SELECT '{"a":1}'::jsonb @> NULL::jsonb`, nil},
+		{`SELECT '{"a":1}'::jsonb ? 'a'`, true},
+		{`SELECT '{"a":1}'::jsonb ? 'b'`, false},
+		{`SELECT '[1,"x"]'::jsonb ? 'x'`, true},
+		{`SELECT '[1,"x"]'::jsonb ? '1'`, false},
+		{`SELECT '"x"'::jsonb ? 'x'`, false},
+		{`SELECT 'null'::jsonb ? 'x'`, false},
+		{`SELECT '{"a":1,"b":2}'::jsonb ?| '{b,c}'`, true},
+		{`SELECT '{"a":1,"b":2}'::jsonb ?| '{c,d}'`, false},
+		{`SELECT '{"a":1,"b":2}'::jsonb ?& '{a,b}'`, true},
+		{`SELECT '{"a":1,"b":2}'::jsonb ?& '{a,c}'`, false},
+		{`SELECT '{"1":true}'::jsonb ? '1'`, true},
+		{`SELECT '{"1":true}'::jsonb ?| '{1,2}'`, true},
+		{`SELECT '[1,"x"]'::jsonb ?| '{x}'`, true},
+		{`SELECT NULL::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)
+		}
+	}
+
+	// Containment needs a jsonb operand on at least one side.
+	if _, err := execSQL(e, `SELECT '{"a":1}' @> '{"a":1}'`); jsonbErrorCode(t, err) != pgerror.CodeDatatypeMismatch {
+		t.Errorf("text @> text error = %v, want 42804", err)
+	}
+}
+
+func TestJSONBConcatAndDelete(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+
+	cases := []struct {
+		sql  string
+		want interface{}
+	}{
+		{`SELECT '{"a":1}'::jsonb || '{"b":2,"a":3}'::jsonb`, `{"a":3,"b":2}`},
+		{`SELECT '[1]'::jsonb || '[2,3]'::jsonb`, `[1,2,3]`},
+		{`SELECT '[1]'::jsonb || '2'::jsonb`, `[1,2]`},
+		{`SELECT '{"a":1}'::jsonb || '[1]'::jsonb`, `[{"a":1},1]`},
+		{`SELECT '1'::jsonb || '2'::jsonb`, `[1,2]`},
+		{`SELECT NULL::jsonb || '{}'::jsonb`, nil},
+		{`SELECT '{"a":1,"b":2}'::jsonb - 'a'`, `{"b":2}`},
+		{`SELECT '{"a":1,"b":2}'::jsonb - '{a,b}'::text[]`, `{}`},
+		{`SELECT '[1,2,3]'::jsonb - 1`, `[1,3]`},
+		{`SELECT '[1,2,3]'::jsonb - -1`, `[1,2]`},
+		{`SELECT '[1,2,3]'::jsonb - 9`, `[1,2,3]`},
+		{`SELECT '["a","b","a"]'::jsonb - 'a'`, `["b"]`},
+		{`SELECT '"x"'::jsonb - 'a'`, `"x"`},
+		{`SELECT NULL::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)
+		}
+	}
+
+	// Text concatenation is unchanged when neither side is jsonb.
+	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.CodeInvalidParameter {
+		t.Errorf("jsonb - integer on object error = %v, want 22023", err)
+	}
+}
+
+func TestJSONBScalarFunctions(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+
+	cases := []struct {
+		sql  string
+		want interface{}
+	}{
+		{`SELECT jsonb_typeof('{"a":1}')`, "object"},
+		{`SELECT jsonb_typeof('[1]')`, "array"},
+		{`SELECT jsonb_typeof('"x"')`, "string"},
+		{`SELECT jsonb_typeof('1.50')`, "number"},
+		{`SELECT jsonb_typeof('true')`, "boolean"},
+		{`SELECT jsonb_typeof('null')`, "null"},
+		{`SELECT jsonb_typeof(NULL)`, nil},
+		{`SELECT jsonb_array_length('[1,2,3]')`, int64(3)},
+		{`SELECT jsonb_array_length('[]')`, int64(0)},
+		{`SELECT jsonb_array_length(NULL)`, nil},
+		{`SELECT jsonb_pretty('{"b":2,"a":[1]}')`, "{\n    \"a\": [\n        1\n    ],\n    \"b\": 2\n}"},
+		{`SELECT jsonb_strip_nulls('{"a":null,"b":{"c":null,"d":1},"e":[null,1]}')`, `{"b":{"d":1},"e":[null,1]}`},
+		{`SELECT jsonb_build_object('b', 1, 'a', 'x')`, `{"a":"x","b":1}`},
+		{`SELECT jsonb_build_object('a', NULL)`, `{"a":null}`},
+		{`SELECT jsonb_build_object('a', '{"b":1}'::jsonb)`, `{"a":{"b":1}}`},
+		{`SELECT jsonb_build_array(1, 'x', NULL, TRUE)`, `[1,"x",null,true]`},
+		{`SELECT jsonb_build_array()`, `[]`},
+		{`SELECT jsonb_build_object()`, `{}`},
+		{`SELECT jsonb_set('{"a":1}', '{a}', '2')`, `{"a":2}`},
+		{`SELECT jsonb_set('{"a":1}', '{b}', '2')`, `{"a":1,"b":2}`},
+		{`SELECT jsonb_set('{"a":1}', '{b}', '2', false)`, `{"a":1}`},
+		{`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('[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 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_set('{"a":1}', '$.b', 1.5)`, `{"a":1,"b":1.5}`},
+		{`SELECT jsonb_insert('{}', '$.b', 2.5)`, `{"b":2.5}`},
+		{`SELECT jsonb_replace('{"b":0}', '$.b', 2.5)`, `{"b":2.5}`},
+		{`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 {
+		if got := jsonbValue(t, e, tc.sql); got != tc.want {
+			t.Errorf("%s = %v (%T), want %v", tc.sql, got, got, tc.want)
+		}
+	}
+
+	// JSON1 scalar functions keep their text surface.
+	if got := jsonbValue(t, e, `SELECT json_type('{"a":1.5}', '$.a')`); got != "real" {
+		t.Fatalf("json_type text surface = %v", got)
+	}
+	if got := jsonbValue(t, e, `SELECT json_set('{"a":1}', '$.b', '2')`); got != `{"a":1,"b":"2"}` {
+		t.Fatalf("json_set text surface = %v", got)
+	}
+
+	for _, tc := range []struct {
+		sql  string
+		code string
+	}{
+		{`SELECT jsonb_array_length('{"a":1}')`, pgerror.CodeInvalidParameter},
+		{`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('[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)
+		}
+	}
+}
+
+func TestJSONBSetReturningFunctionsInFrom(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+
+	res := execMust(t, e, `SELECT * FROM jsonb_each('{"b":2,"a":{"c":1}}')`)
+	if len(res.Columns) != 2 || res.Columns[0] != "key" || res.Columns[1] != "value" {
+		t.Fatalf("jsonb_each columns = %v", res.Columns)
+	}
+	if res.RowCount != 2 || res.Rows[0][0] != "a" || res.Rows[0][1] != `{"c":1}` || res.Rows[1][0] != "b" || res.Rows[1][1] != `2` {
+		t.Fatalf("jsonb_each rows = %v", res.Rows)
+	}
+	if len(res.ColumnTypes) != 2 || res.ColumnTypes[1] != "JSONB" {
+		t.Fatalf("jsonb_each column types = %v", res.ColumnTypes)
+	}
+
+	res = execMust(t, e, `SELECT e.key, e.value FROM jsonb_each('{"a":1}') AS e`)
+	if res.RowCount != 1 || res.Rows[0][0] != "a" || res.Rows[0][1] != `1` {
+		t.Fatalf("aliased jsonb_each rows = %v", res.Rows)
+	}
+
+	res = execMust(t, e, `SELECT key, value FROM jsonb_each_text('{"a":null,"b":true}')`)
+	if res.RowCount != 2 || res.Rows[0][0] != "a" || res.Rows[0][1] != nil || res.Rows[1][1] != "true" {
+		t.Fatalf("jsonb_each_text rows = %v", res.Rows)
+	}
+
+	res = execMust(t, e, `SELECT value FROM jsonb_array_elements('[1,{"a":2}]')`)
+	if res.RowCount != 2 || res.Rows[0][0] != `1` || res.Rows[1][0] != `{"a":2}` {
+		t.Fatalf("jsonb_array_elements rows = %v", res.Rows)
+	}
+	if res.ColumnTypes[0] != "JSONB" {
+		t.Fatalf("jsonb_array_elements type = %v", res.ColumnTypes)
+	}
+
+	res = execMust(t, e, `SELECT value FROM jsonb_array_elements_text('[1,null,"x"]')`)
+	if res.RowCount != 3 || res.Rows[0][0] != "1" || res.Rows[1][0] != nil || res.Rows[2][0] != "x" {
+		t.Fatalf("jsonb_array_elements_text rows = %v", res.Rows)
+	}
+
+	res = execMust(t, e, `SELECT jsonb_object_keys FROM jsonb_object_keys('{"b":1,"a":2}')`)
+	if res.RowCount != 2 || res.Rows[0][0] != "a" || res.Rows[1][0] != "b" {
+		t.Fatalf("jsonb_object_keys rows = %v", res.Rows)
+	}
+
+	// A NULL document produces no rows.
+	res = execMust(t, e, `SELECT * FROM jsonb_array_elements(NULL)`)
+	if res.RowCount != 0 {
+		t.Fatalf("jsonb_array_elements(NULL) rows = %v", res.Rows)
+	}
+
+	// Wrong document types and projections of set-returning functions are
+	// rejected clearly instead of executing as scalar calls.
+	for _, tc := range []struct {
+		sql  string
+		code string
+	}{
+		{`SELECT * FROM jsonb_array_elements('{"a":1}')`, pgerror.CodeInvalidParameter},
+		{`SELECT * FROM jsonb_each('[1]')`, pgerror.CodeInvalidParameter},
+		{`SELECT * FROM jsonb_object_keys('[1]')`, pgerror.CodeInvalidParameter},
+		{`SELECT * FROM jsonb_each('null')`, pgerror.CodeInvalidParameter},
+		{`SELECT * FROM jsonb_array_elements('null')`, pgerror.CodeInvalidParameter},
+		{`SELECT jsonb_each('{"a":1}')`, pgerror.CodeFeatureNotSupported},
+		{`SELECT jsonb_array_elements('[1]')`, pgerror.CodeFeatureNotSupported},
+		{`SELECT * FROM jsonb_each('{"a":1}') AS e JOIN docs d ON TRUE`, pgerror.CodeFeatureNotSupported},
+	} {
+		if _, err := execSQL(e, tc.sql); jsonbErrorCode(t, err) != tc.code {
+			t.Errorf("%s error = %v, want %s", tc.sql, err, tc.code)
+		}
+	}
+}
+
+func TestJSONBEqualityOrderingAndDistinct(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE docs (id INTEGER PRIMARY KEY, doc JSONB)")
+	execMust(t, e, `INSERT INTO docs VALUES (1, '{"b":1,"a":2}'), (2, '{"a":2,"b":1}'), (3, '[1,2]')`)
+
+	if got := jsonbValue(t, e, `SELECT '{"b":1,"a":2}'::jsonb = '{"a":2, "b": 1}'::jsonb`); got != true {
+		t.Fatalf("jsonb equality = %v, want true", got)
+	}
+	if got := jsonbValue(t, e, `SELECT '{"a":1}'::jsonb = '[1]'::jsonb`); got != false {
+		t.Fatalf("jsonb inequality = %v, want false", got)
+	}
+	if got := jsonbValue(t, e, `SELECT doc IS NOT DISTINCT FROM '{"a":2,"b":1}'::jsonb FROM docs WHERE id = 2`); got != true {
+		t.Fatalf("IS NOT DISTINCT FROM = %v", got)
+	}
+	if got := jsonbValue(t, e, `SELECT '{"b":1,"a":2}'::jsonb IS DISTINCT FROM '{"a":2,"b":1.0}'::jsonb`); got != false {
+		t.Fatalf("IS DISTINCT FROM key-order/number-spelling = %v, want false", got)
+	}
+
+	res := execMust(t, e, `SELECT COUNT(*) FROM docs WHERE doc = '{"a":2,"b":1}'::jsonb`)
+	if res.Rows[0][0] != int64(2) {
+		t.Fatalf("key-order-independent equality matched %v rows, want 2", res.Rows[0][0])
+	}
+
+	res = execMust(t, e, `SELECT DISTINCT doc FROM docs ORDER BY doc`)
+	if res.RowCount != 2 {
+		t.Fatalf("DISTINCT jsonb rows = %v, want 2", res.Rows)
+	}
+
+	// Canonical numeric scalars order numerically, and non-scalars order by
+	// canonical text.
+	execMust(t, e, "CREATE TABLE scalars (id INTEGER PRIMARY KEY, v JSONB)")
+	execMust(t, e, `INSERT INTO scalars VALUES (1, '10'), (2, '2')`)
+	res = execMust(t, e, `SELECT v FROM scalars ORDER BY v`)
+	if res.RowCount != 2 || res.Rows[0][0] != `2` || res.Rows[1][0] != `10` {
+		t.Fatalf("jsonb scalar ordering = %v", res.Rows)
+	}
+	res = execMust(t, e, `SELECT doc FROM docs WHERE id IN (1,3) ORDER BY doc`)
+	if res.RowCount != 2 || res.Rows[0][0] != `[1,2]` || res.Rows[1][0] != `{"a":2,"b":1}` {
+		t.Fatalf("jsonb text ordering = %v", res.Rows)
+	}
+
+	// A jsonb column cannot be compared with a number.
+	if _, err := execSQL(e, `SELECT id FROM docs WHERE doc = 5`); err == nil {
+		t.Fatal("jsonb = integer was accepted")
+	}
+}
+
+func TestJSONBRejectedShapes(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE docs (id INTEGER PRIMARY KEY, doc JSONB)")
+
+	for _, sql := range []string{
+		`SELECT jsonb_path_exists('{"a":1}', '$.a')`,
+		`SELECT jsonb_path_query('{"a":1}', '$.a')`,
+		`CREATE INDEX docs_gin ON docs USING GIN (doc)`,
+		`CREATE INDEX docs_expr ON docs ((doc -> 'a'))`,
+		`CREATE TABLE bad_arrays (doc JSONB[])`,
+	} {
+		if _, err := execSQL(e, sql); jsonbErrorCode(t, err) != pgerror.CodeFeatureNotSupported {
+			t.Errorf("%s error = %v, want 0A000", sql, err)
+		}
+	}
+
+	// A scalar extraction index over jsonb is a text expression and stays legal.
+	execMust(t, e, `CREATE INDEX docs_kind ON docs ((doc ->> 'kind'))`)
+}
+
+func TestJSONBColumnMetadataAndDefaults(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, `CREATE TABLE docs (id INTEGER PRIMARY KEY, doc JSONB DEFAULT '{"b":2,"a":1}')`)
+	execMust(t, e, `INSERT INTO docs (id) VALUES (1)`)
+	res := execMust(t, e, `SELECT doc FROM docs`)
+	if res.Rows[0][0] != `{"a":1,"b":2}` {
+		t.Fatalf("canonical default = %v", res.Rows[0][0])
+	}
+	if len(res.ColumnTypes) != 1 || res.ColumnTypes[0] != "JSONB" {
+		t.Fatalf("column types = %v, want [JSONB]", res.ColumnTypes)
+	}
+
+	// ALTER TABLE backfills the canonical default into existing rows.
+	execMust(t, e, "CREATE TABLE raw (id INTEGER PRIMARY KEY)")
+	execMust(t, e, "INSERT INTO raw VALUES (1)")
+	execMust(t, e, `ALTER TABLE raw ADD COLUMN doc JSONB DEFAULT '{"b":2,"a":1}'`)
+	res = execMust(t, e, `SELECT doc FROM raw`)
+	if res.Rows[0][0] != `{"a":1,"b":2}` {
+		t.Fatalf("backfilled default = %v", res.Rows[0][0])
+	}
+
+	// UPDATE canonicalizes too.
+	execMust(t, e, `UPDATE raw SET doc = '{"z": 1.50}'`)
+	res = execMust(t, e, `SELECT doc FROM raw`)
+	if res.Rows[0][0] != `{"z":1.5}` {
+		t.Fatalf("updated doc = %v", res.Rows[0][0])
+	}
+}
+
+func TestCanonicalJSONBNumber(t *testing.T) {
+	cases := map[string]string{
+		"0":                              "0",
+		"-0":                             "0",
+		"-0.0":                           "0",
+		"1":                              "1",
+		"1.0":                            "1",
+		"1.50":                           "1.5",
+		"1e2":                            "100",
+		"1E+2":                           "100",
+		"1e-2":                           "0.01",
+		"-12.340":                        "-12.34",
+		"123456789012345678901234567890": "123456789012345678901234567890",
+	}
+	for input, want := range cases {
+		got, err := canonicalJSONBNumber(json.Number(input))
+		if err != nil || got != want {
+			t.Errorf("canonicalJSONBNumber(%q) = %q, %v; want %q", input, got, err, want)
+		}
+	}
+	for _, input := range []string{"1e999999", "1e-999999"} {
+		if _, err := canonicalJSONBNumber(json.Number(input)); err == nil {
+			t.Errorf("canonicalJSONBNumber(%q) accepted an unbounded expansion", input)
+		}
+	}
+}
+
+// JSON operations must keep working from a derived table and a CTE, where the
+// column type travels through Result column metadata.
+func TestJSONBTypePropagation(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE docs (id INTEGER PRIMARY KEY, doc JSONB)")
+	execMust(t, e, `INSERT INTO docs VALUES (1, '{"a":1}')`)
+
+	if got := jsonbValue(t, e, `SELECT doc -> 'a' FROM (SELECT doc FROM docs) AS d`); got != `1` {
+		t.Fatalf("derived jsonb -> = %v", got)
+	}
+	if got := jsonbValue(t, e, `SELECT doc ? 'a' FROM (SELECT doc FROM docs) AS d`); got != true {
+		t.Fatalf("derived jsonb ? = %v", got)
+	}
+	if got := jsonbValue(t, e, `WITH c AS (SELECT doc FROM docs) SELECT doc @> '{"a":1}' FROM c`); got != true {
+		t.Fatalf("CTE jsonb @> = %v", got)
+	}
+	if got := jsonbValue(t, e, `SELECT COALESCE(doc, '{}'::jsonb) ->> 'a' FROM docs`); got != "1" {
+		t.Fatalf("COALESCE jsonb ->> = %v", got)
+	}
+
+	// A jsonb column keeps its type when it is the left side of a join and when
+	// it is referenced through an alias.
+	res := execMust(t, e, `SELECT a.id FROM docs AS a JOIN docs AS b ON a.id = b.id WHERE a.doc @> '{"a":1}'`)
+	if res.RowCount != 1 || res.Rows[0][0] != int64(1) {
+		t.Fatalf("joined jsonb containment rows = %v", res.Rows)
+	}
+	if got := jsonbValue(t, e, `SELECT a.doc -> 'a' FROM docs AS a`); got != `1` {
+		t.Fatalf("aliased jsonb -> = %v", got)
+	}
+	if got := jsonbValue(t, e, `SELECT MAX(jsonb_build_object('a', doc)) FROM docs`); got != `{"a":{"a":1}}` {
+		t.Fatalf("jsonb build function inside an aggregate = %v", got)
+	}
+}
+
+func TestJSONBRejectsUnboundedCanonicalExpansion(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	if _, err := execSQL(e, `SELECT '{"a":1e999999}'::jsonb`); !strings.Contains(err.Error(), "invalid input syntax") {
+		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])
+	}
+}
+
+// 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)
+	}
+}
+
+// PostgreSQL array containment is duplicate-insensitive: each candidate
+// element only needs some matching container element, and duplicates may
+// consume the same match.
+func TestJSONBArrayContainmentDuplicates(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	cases := []struct {
+		sql  string
+		want interface{}
+	}{
+		{`SELECT '[1]'::jsonb @> '[1,1]'::jsonb`, true},
+		{`SELECT '[1,1]'::jsonb @> '[1]'::jsonb`, true},
+		{`SELECT '[1,2,2]'::jsonb @> '[2,2]'::jsonb`, true},
+		{`SELECT '[1,2]'::jsonb @> '[2,2]'::jsonb`, true},
+		{`SELECT '[1,2]'::jsonb @> '[2,3]'::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)
+	}
+}
+
+// JSON1 $ path patch values may be non-integral numeric expressions. They are
+// normalized to json.Number before encoding, including values read from REAL
+// columns.
+func TestJSONBJSON1PatchFloatValues(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE realvals (id INTEGER PRIMARY KEY, r REAL)")
+	execMust(t, e, `INSERT INTO realvals VALUES (1, 1.5)`)
+
+	for _, tc := range []struct {
+		sql  string
+		want string
+	}{
+		{`SELECT jsonb_set('{"a":1}', '$.b', 1.5)`, `{"a":1,"b":1.5}`},
+		{`SELECT jsonb_set('{"a":1}', '$.b', 2.5)`, `{"a":1,"b":2.5}`},
+		{`SELECT jsonb_insert('{}', '$.b', 2.5)`, `{"b":2.5}`},
+		{`SELECT jsonb_replace('{"b":0}', '$.b', 2.5)`, `{"b":2.5}`},
+		{`SELECT jsonb_set('{"a":1}', '$.b', r) FROM realvals`, `{"a":1,"b":1.5}`},
+		{`SELECT jsonb_insert('{}', '$.b', r) FROM realvals`, `{"b":1.5}`},
+	} {
+		if got := jsonbValue(t, e, tc.sql); got != tc.want {
+			t.Errorf("%s = %v, want %s", tc.sql, got, tc.want)
+		}
+	}
+}
+
+// HAVING evaluates simple CASE and IS DISTINCT FROM through the aggregate
+// expression path; it must use the same exact jsonb comparison as the
+// top-level evaluators.
+func TestJSONBAggregateHavingExactJSONB(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE ex (id INTEGER PRIMARY KEY, doc JSONB)")
+	execMust(t, e, `INSERT INTO ex VALUES (1, '1'), (2, '1.0000000000000000001')`)
+
+	if got := jsonbValue(t, e, `SELECT COUNT(*) FROM ex GROUP BY doc HAVING '1'::jsonb IS DISTINCT FROM '1.0000000000000000001'::jsonb`); got != int64(1) {
+		t.Fatalf("HAVING IS DISTINCT FROM group count = %v, want 1", got)
+	}
+	res := execMust(t, e, `SELECT doc, COUNT(*) FROM ex GROUP BY doc HAVING '1'::jsonb IS DISTINCT FROM '1.0000000000000000001'::jsonb ORDER BY doc`)
+	if res.RowCount != 2 {
+		t.Fatalf("HAVING IS DISTINCT FROM rows = %v, want 2 groups", res.Rows)
+	}
+	res = execMust(t, e, `SELECT doc, COUNT(*) FROM ex GROUP BY doc HAVING '1'::jsonb IS NOT DISTINCT FROM '1.0000000000000000001'::jsonb`)
+	if res.RowCount != 0 {
+		t.Fatalf("HAVING IS NOT DISTINCT FROM rows = %v, want 0", res.Rows)
+	}
+	res = execMust(t, e, `SELECT doc, COUNT(*) FROM ex GROUP BY doc HAVING CASE '1'::jsonb WHEN '1.0000000000000000001'::jsonb THEN FALSE ELSE TRUE END ORDER BY doc`)
+	if res.RowCount != 2 {
+		t.Fatalf("HAVING CASE rows = %v, want 2 groups", res.Rows)
+	}
+}

+ 34 - 5
pkg/executor/result_types.go

@@ -385,6 +385,8 @@ func castResultType(name string) (string, bool) {
 		return "TEXT", true
 	case "BLOB":
 		return "BLOB", true
+	case "JSONB":
+		return "JSONB", true
 	default:
 		return "", false
 	}
@@ -464,10 +466,16 @@ func functionResultType(fn *parser.FunctionCall, resolve columnTypeResolver) str
 		return "DOUBLE"
 	case "AVG":
 		return "DOUBLE"
-	case "ARRAY_LENGTH":
+	case "ARRAY_LENGTH", "JSONB_ARRAY_LENGTH":
 		return "INTEGER"
-	case "MIN", "MAX":
-		// MIN/MAX return one of the input values unchanged.
+	case "JSONB_TYPEOF", "JSONB_PRETTY":
+		return "TEXT"
+	case "TO_JSONB", "JSONB_SET", "JSONB_INSERT", "JSONB_REPLACE", "JSONB_REMOVE",
+		"JSONB_STRIP_NULLS", "JSONB_BUILD_OBJECT", "JSONB_BUILD_ARRAY":
+		return "JSONB"
+	case "MIN", "MAX", "COALESCE", "IFNULL", "NVL", "NULLIF":
+		// These return one of the input values unchanged, so the result type
+		// follows the first argument (and therefore keeps JSONB).
 		return firstArgType(fn, resolve)
 	default:
 		return "TEXT"
@@ -500,13 +508,34 @@ func sumResultType(argType string) string {
 func binaryResultType(e *parser.BinaryExpr, resolve columnTypeResolver) string {
 	switch e.Op {
 	case lexer.TokenEq, lexer.TokenNeq, lexer.TokenLt, lexer.TokenLte, lexer.TokenGt, lexer.TokenGte,
-		lexer.TokenAND, lexer.TokenOR:
+		lexer.TokenAND, lexer.TokenOR,
+		lexer.TokenAtArrow, lexer.TokenLtAt,
+		lexer.TokenQuestion, lexer.TokenQuestionPipe, lexer.TokenQuestionAmp:
 		return "BOOLEAN"
 	case lexer.TokenConcat:
+		// jsonb || jsonb merges or concatenates; text || text concatenates.
+		if strings.EqualFold(inferExprType(e.Left, resolve), "JSONB") ||
+			strings.EqualFold(inferExprType(e.Right, resolve), "JSONB") {
+			return "JSONB"
+		}
+		return "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"
+		}
+		return "TEXT"
+	case lexer.TokenArrowText, lexer.TokenHashArrowText:
 		return "TEXT"
 	case lexer.TokenBitAnd, lexer.TokenBitOr, lexer.TokenShiftLeft, lexer.TokenShiftRight:
 		return "BIGINT"
-	case lexer.TokenPlus, lexer.TokenMinus, lexer.TokenStar, lexer.TokenSlash, lexer.TokenPercent:
+	case lexer.TokenMinus:
+		if strings.EqualFold(inferExprType(e.Left, resolve), "JSONB") {
+			return "JSONB"
+		}
+		fallthrough
+	case lexer.TokenPlus, lexer.TokenStar, lexer.TokenSlash, lexer.TokenPercent:
 		left := inferExprType(e.Left, resolve)
 		right := inferExprType(e.Right, resolve)
 		switch {

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

+ 7 - 0
pkg/httpserver/handler.go

@@ -543,6 +543,13 @@ func sqlStateHTTPError(err error) *HTTPError {
 			Message: message,
 		}
 	}
+	if errors.Is(err, pgerror.ErrInvalidParameter) {
+		return &HTTPError{
+			Status:  http.StatusBadRequest,
+			Code:    pgerror.CodeInvalidParameter,
+			Message: message,
+		}
+	}
 	var analysisErr *analyzer.AnalysisError
 	if errors.Is(err, pgerror.ErrDatatypeMismatch) || (errors.As(err, &analysisErr) && analysisErr.Type == analyzer.ErrTypeMismatch) {
 		return &HTTPError{

+ 62 - 0
pkg/lexer/lexer.go

@@ -130,6 +130,10 @@ func (l *Lexer) NextToken() Token {
 			l.readChar()
 			tok.Type = TokenShiftLeft
 			tok.Literal = "<<"
+		} else if l.peekChar() == '@' {
+			l.readChar()
+			tok.Type = TokenLtAt
+			tok.Literal = "<@"
 		} else {
 			tok.Type = TokenLt
 			tok.Literal = "<"
@@ -171,6 +175,64 @@ func (l *Lexer) NextToken() Token {
 			tok.Literal = "|"
 			l.readChar()
 		}
+	case '#':
+		// JSONB path and delete operators: #>, #>>, #-.
+		if l.peekChar() == '>' {
+			l.readChar()
+			if l.peekChar() == '>' {
+				l.readChar()
+				tok.Type = TokenHashArrowText
+				tok.Literal = "#>>"
+			} else {
+				tok.Type = TokenHashArrow
+				tok.Literal = "#>"
+			}
+			l.readChar()
+		} else if l.peekChar() == '-' {
+			l.readChar()
+			tok.Type = TokenHashMinus
+			tok.Literal = "#-"
+			l.readChar()
+		} else {
+			tok.Type = TokenError
+			tok.Literal = "unexpected character: #"
+			l.readChar()
+		}
+	case '@':
+		// JSONB containment and jsonpath operators: @>, @@, @?.
+		switch l.peekChar() {
+		case '>':
+			l.readChar()
+			tok.Type = TokenAtArrow
+			tok.Literal = "@>"
+		case '@':
+			l.readChar()
+			tok.Type = TokenAtAt
+			tok.Literal = "@@"
+		case '?':
+			l.readChar()
+			tok.Type = TokenAtQuestion
+			tok.Literal = "@?"
+		default:
+			tok.Type = TokenError
+			tok.Literal = "unexpected character: @"
+		}
+		l.readChar()
+	case '?':
+		switch l.peekChar() {
+		case '|':
+			l.readChar()
+			tok.Type = TokenQuestionPipe
+			tok.Literal = "?|"
+		case '&':
+			l.readChar()
+			tok.Type = TokenQuestionAmp
+			tok.Literal = "?&"
+		default:
+			tok.Type = TokenQuestion
+			tok.Literal = "?"
+		}
+		l.readChar()
 	case '-':
 		if l.peekChar() == '-' {
 			// Line comment

+ 57 - 34
pkg/lexer/token.go

@@ -37,6 +37,19 @@ const (
 	TokenShiftLeft  // <<
 	TokenShiftRight // >>
 
+	// JSONB operators. They are PostgreSQL operators, not SQLite ones, and the
+	// parser restricts them to jsonb operands.
+	TokenHashArrow     // #>
+	TokenHashArrowText // #>>
+	TokenHashMinus     // #-
+	TokenAtArrow       // @>
+	TokenAtAt          // @@
+	TokenAtQuestion    // @?
+	TokenLtAt          // <@
+	TokenQuestion      // ?
+	TokenQuestionPipe  // ?|
+	TokenQuestionAmp   // ?&
+
 	// Punctuation
 	TokenLParen    // (
 	TokenRParen    // )
@@ -400,40 +413,50 @@ func (t Token) IsOperator() bool {
 }
 
 var tokenNames = map[TokenType]string{
-	TokenEOF:        "EOF",
-	TokenError:      "ERROR",
-	TokenComment:    "COMMENT",
-	TokenIdent:      "IDENT",
-	TokenNumber:     "NUMBER",
-	TokenString:     "STRING",
-	TokenBlob:       "BLOB",
-	TokenPlus:       "+",
-	TokenMinus:      "-",
-	TokenStar:       "*",
-	TokenSlash:      "/",
-	TokenPercent:    "%",
-	TokenConcat:     "||",
-	TokenArrow:      "->",
-	TokenArrowText:  "->>",
-	TokenEq:         "=",
-	TokenNeq:        "<>",
-	TokenLt:         "<",
-	TokenLte:        "<=",
-	TokenGt:         ">",
-	TokenGte:        ">=",
-	TokenBitAnd:     "&",
-	TokenBitOr:      "|",
-	TokenBitNot:     "~",
-	TokenShiftLeft:  "<<",
-	TokenShiftRight: ">>",
-	TokenLParen:     "(",
-	TokenRParen:     ")",
-	TokenComma:      ",",
-	TokenSemicolon:  ";",
-	TokenDot:        ".",
-	TokenColonColon: "::",
-	TokenLBracket:   "[",
-	TokenRBracket:   "]",
+	TokenEOF:           "EOF",
+	TokenError:         "ERROR",
+	TokenComment:       "COMMENT",
+	TokenIdent:         "IDENT",
+	TokenNumber:        "NUMBER",
+	TokenString:        "STRING",
+	TokenBlob:          "BLOB",
+	TokenPlus:          "+",
+	TokenMinus:         "-",
+	TokenStar:          "*",
+	TokenSlash:         "/",
+	TokenPercent:       "%",
+	TokenConcat:        "||",
+	TokenArrow:         "->",
+	TokenArrowText:     "->>",
+	TokenEq:            "=",
+	TokenNeq:           "<>",
+	TokenLt:            "<",
+	TokenLte:           "<=",
+	TokenGt:            ">",
+	TokenGte:           ">=",
+	TokenBitAnd:        "&",
+	TokenBitOr:         "|",
+	TokenBitNot:        "~",
+	TokenShiftLeft:     "<<",
+	TokenShiftRight:    ">>",
+	TokenHashArrow:     "#>",
+	TokenHashArrowText: "#>>",
+	TokenHashMinus:     "#-",
+	TokenAtArrow:       "@>",
+	TokenAtAt:          "@@",
+	TokenAtQuestion:    "@?",
+	TokenLtAt:          "<@",
+	TokenQuestion:      "?",
+	TokenQuestionPipe:  "?|",
+	TokenQuestionAmp:   "?&",
+	TokenLParen:        "(",
+	TokenRParen:        ")",
+	TokenComma:         ",",
+	TokenSemicolon:     ";",
+	TokenDot:           ".",
+	TokenColonColon:    "::",
+	TokenLBracket:      "[",
+	TokenRBracket:      "]",
 }
 
 func (t TokenType) String() string {

+ 109 - 0
pkg/parser/jsonb_test.go

@@ -0,0 +1,109 @@
+package parser
+
+import (
+	"errors"
+	"testing"
+
+	"github.com/danfragoso/pizzasql-next/pkg/lexer"
+	"github.com/danfragoso/pizzasql-next/pkg/pgerror"
+)
+
+// The jsonb path/containment/existence operators parse at their intended
+// precedence and keep the JSON1 arrows working.
+func TestParseJSONBOperators(t *testing.T) {
+	cases := map[string]lexer.TokenType{
+		`SELECT doc -> 'a' FROM docs`:    lexer.TokenArrow,
+		`SELECT doc ->> 'a' FROM docs`:   lexer.TokenArrowText,
+		`SELECT doc #> '{a}' FROM docs`:  lexer.TokenHashArrow,
+		`SELECT doc #>> '{a}' FROM docs`: lexer.TokenHashArrowText,
+		`SELECT doc #- '{a}' FROM docs`:  lexer.TokenHashMinus,
+		`SELECT doc @> '{}' FROM docs`:   lexer.TokenAtArrow,
+		`SELECT doc <@ '{}' FROM docs`:   lexer.TokenLtAt,
+		`SELECT doc ? 'a' FROM docs`:     lexer.TokenQuestion,
+		`SELECT doc ?| '{a}' FROM docs`:  lexer.TokenQuestionPipe,
+		`SELECT doc ?& '{a}' FROM docs`:  lexer.TokenQuestionAmp,
+	}
+	for sql, wantOp := range cases {
+		stmt := parse(t, sql)
+		sel := stmt.(*SelectStmt)
+		binary, ok := sel.Columns[0].Expr.(*BinaryExpr)
+		if !ok || binary.Op != wantOp {
+			t.Fatalf("%s: expression = %#v, want operator %v", sql, sel.Columns[0].Expr, wantOp)
+		}
+	}
+
+	// Containment binds looser than extraction: doc -> 'a' @> '1'.
+	sel := parse(t, `SELECT doc -> 'a' @> '1' FROM docs`).(*SelectStmt)
+	binary := sel.Columns[0].Expr.(*BinaryExpr)
+	if binary.Op != lexer.TokenAtArrow {
+		t.Fatalf("containment operator = %v", binary.Op)
+	}
+	if left, ok := binary.Left.(*BinaryExpr); !ok || left.Op != lexer.TokenArrow {
+		t.Fatalf("left operand = %#v, want ->", binary.Left)
+	}
+
+	// A jsonb cast is a normal cast target and keeps the extract/compare chain.
+	sel = parse(t, `SELECT CAST(doc AS JSONB) -> 'a' = '1'::jsonb FROM docs`).(*SelectStmt)
+	if eq, ok := sel.Columns[0].Expr.(*BinaryExpr); !ok || eq.Op != lexer.TokenEq {
+		t.Fatalf("cast chain = %#v, want =", sel.Columns[0].Expr)
+	}
+
+	// jsonpath operators are recognized and rejected as unsupported.
+	for _, sql := range []string{
+		`SELECT doc @? '$.a' FROM docs`,
+		`SELECT doc @@ '$.a' FROM docs`,
+	} {
+		l := lexer.New(sql)
+		if _, err := New(l).Parse(); !errors.Is(err, pgerror.ErrFeatureNotSupported) {
+			t.Errorf("%s error = %v, want 0A000", sql, err)
+		}
+	}
+}
+
+// Non-btree index methods, including the GIN method used for jsonb in
+// PostgreSQL, are rejected instead of creating a btree index with different
+// behavior.
+func TestParseCreateIndexMethod(t *testing.T) {
+	if _, err := parseAllowError(t, `CREATE INDEX i ON docs USING BTREE (doc)`); err != nil {
+		t.Fatalf("BTREE index: %v", err)
+	}
+	for _, sql := range []string{
+		`CREATE INDEX i ON docs USING GIN (doc)`,
+		`CREATE INDEX i ON docs USING GIST (doc)`,
+	} {
+		if _, err := parseAllowError(t, sql); !errors.Is(err, pgerror.ErrFeatureNotSupported) {
+			t.Errorf("%s error = %v, want 0A000", sql, err)
+		}
+	}
+}
+
+// The jsonb set-returning functions are recognized as FROM table functions,
+// while unknown function-shaped table references stay unsupported.
+func TestParseJSONBTableFunctions(t *testing.T) {
+	for _, sql := range []string{
+		`SELECT * FROM jsonb_each('{"a":1}')`,
+		`SELECT * FROM jsonb_each_text('{"a":1}') AS e`,
+		`SELECT * FROM jsonb_array_elements('[1]')`,
+		`SELECT * FROM jsonb_array_elements_text('[1]')`,
+		`SELECT * FROM jsonb_object_keys('{"a":1}')`,
+	} {
+		stmt := parse(t, sql)
+		sel := stmt.(*SelectStmt)
+		if len(sel.From) != 1 || sel.From[0].Function == nil {
+			t.Fatalf("%s: FROM = %#v, want a table function", sql, sel.From)
+		}
+	}
+
+	l := lexer.New(`SELECT * FROM mystery('x')`)
+	if _, err := New(l).Parse(); !errors.Is(err, pgerror.ErrFeatureNotSupported) {
+		t.Fatalf("unknown table function error = %v, want 0A000", err)
+	}
+}
+
+// parseAllowError parses without failing the test, so a rejected statement can
+// be inspected.
+func parseAllowError(t *testing.T, sql string) (Statement, error) {
+	t.Helper()
+	l := lexer.New(sql)
+	return New(l).Parse()
+}

+ 78 - 17
pkg/parser/parser.go

@@ -544,7 +544,7 @@ func (p *Parser) parseTableRefs() ([]TableRef, error) {
 			continue
 		}
 		if len(tables) > 1 || table.Join != nil {
-			return nil, pgerror.Unsupported("unnest(array) in FROM must be the only table reference; joins around a table function are not supported")
+			return nil, pgerror.Unsupported("a table function in FROM must be the only table reference; joins around a table function are not supported")
 		}
 	}
 
@@ -617,11 +617,12 @@ func (p *Parser) parseTableRef(allowFunction bool) (*TableRef, error) {
 	ref.Name = p.curToken.Literal
 	p.nextToken()
 
-	// Table function: FROM unnest(array). Only unnest is recognized; other
-	// function names in FROM are reported as an unknown table.
+	// Table function in FROM. unnest(array) and the jsonb set-returning
+	// functions are the only recognized forms; other function names in FROM are
+	// reported as an unsupported table function.
 	if allowFunction && p.curTokenIs(lexer.TokenLParen) {
-		if !strings.EqualFold(ref.Name, "unnest") {
-			return nil, pgerror.Unsupported("table function %s() is not supported; only unnest(array) is available in FROM", ref.Name)
+		if !isTableFunctionName(ref.Name) {
+			return nil, pgerror.Unsupported("table function %s() is not supported; FROM accepts unnest(array), jsonb_each(jsonb), jsonb_each_text(jsonb), jsonb_array_elements(jsonb), jsonb_array_elements_text(jsonb), and jsonb_object_keys(jsonb)", ref.Name)
 		}
 		call, err := p.parseFunctionCall(ref.Name)
 		if err != nil {
@@ -1422,6 +1423,18 @@ func (p *Parser) parseDataType() (*DataType, error) {
 	return dt, nil
 }
 
+// isTableFunctionName reports whether name is a set-returning function the
+// executor can materialize as the single FROM item.
+func isTableFunctionName(name string) bool {
+	switch strings.ToLower(name) {
+	case "unnest", "jsonb_each", "jsonb_each_text", "jsonb_array_elements",
+		"jsonb_array_elements_text", "jsonb_object_keys":
+		return true
+	default:
+		return false
+	}
+}
+
 // isSupportedArrayElementType reports whether an element type name can be
 // encoded as an array element. The list mirrors the element types the executor
 // canonicalizes and the pgserver advertises array OIDs for.
@@ -1849,6 +1862,22 @@ func (p *Parser) parseCreateIndex(unique bool) (*CreateIndexStmt, error) {
 	stmt.Table = p.curToken.Literal
 	p.nextToken()
 
+	// PostgreSQL index methods. The engine only implements an ordered btree
+	// index, so a request for GIN (the jsonb index method) or any other method is
+	// rejected instead of silently creating a btree index with different
+	// performance and constraint behavior.
+	if p.curTokenIs(lexer.TokenUSING) {
+		p.nextToken()
+		if !p.curTokenIs(lexer.TokenIdent) {
+			return nil, p.curError("expected index method after USING")
+		}
+		method := strings.ToUpper(p.curToken.Literal)
+		p.nextToken()
+		if method != "BTREE" {
+			return nil, pgerror.Unsupported("index method %s is not supported; only BTREE indexes are implemented", method)
+		}
+	}
+
 	// Expect (
 	if !p.curTokenIs(lexer.TokenLParen) {
 		return nil, p.curError("expected (")
@@ -2587,6 +2616,21 @@ func (p *Parser) parseComparisonExpr() (Expr, error) {
 		return nil, p.curError("expected IN, BETWEEN, or LIKE after NOT")
 	}
 
+	// JSONB containment and existence operators share the comparison layer:
+	// they bind looser than + and || and tighter than NOT/AND/OR.
+	if isJSONBPredicateOp(p.curToken.Type) {
+		op := p.curToken.Type
+		p.nextToken()
+		right, err := p.parseBitwiseExpr()
+		if err != nil {
+			return nil, err
+		}
+		return &BinaryExpr{Left: left, Op: op, Right: right}, nil
+	}
+	if p.curTokenIs(lexer.TokenAtQuestion) || p.curTokenIs(lexer.TokenAtAt) {
+		return nil, pgerror.Unsupported("jsonpath operators @? and @@ are not supported")
+	}
+
 	// Handle comparison operators, including the quantified forms
 	// `expr op ANY(array)` and `expr op ALL(array)`.
 	if isComparisonOp(p.curToken.Type) {
@@ -2690,6 +2734,17 @@ func isComparisonOp(t lexer.TokenType) bool {
 	return false
 }
 
+// isJSONBPredicateOp reports whether t is a jsonb containment or key-existence
+// operator. They parse at comparison precedence as a single binary predicate.
+func isJSONBPredicateOp(t lexer.TokenType) bool {
+	switch t {
+	case lexer.TokenAtArrow, lexer.TokenLtAt,
+		lexer.TokenQuestion, lexer.TokenQuestionPipe, lexer.TokenQuestionAmp:
+		return true
+	}
+	return false
+}
+
 // parseBitwiseExpr parses the SQLite bitwise layer: << >> & | bind more tightly
 // than comparisons but more loosely than + and -.
 func (p *Parser) parseBitwiseExpr() (Expr, error) {
@@ -2823,7 +2878,8 @@ func (p *Parser) parseMulExpr() (Expr, error) {
 	return left, nil
 }
 
-// parseJSONExpr parses the JSON extraction operators -> and ->>.
+// parseJSONExpr parses the JSON extraction and path operators ->, ->>, #>,
+// #>>, and #-.
 //
 // Precedence: they bind tighter than multiplicative, additive/concatenation,
 // bitwise, and comparison operators, and looser than unary -, +, and ~. So
@@ -2836,17 +2892,21 @@ func (p *Parser) parseJSONExpr() (Expr, error) {
 		return nil, err
 	}
 
-	for p.curTokenIs(lexer.TokenArrow) || p.curTokenIs(lexer.TokenArrowText) {
-		op := p.curToken.Type
-		p.nextToken()
-		right, err := p.parseUnaryExpr()
-		if err != nil {
-			return nil, err
+	for {
+		switch p.curToken.Type {
+		case lexer.TokenArrow, lexer.TokenArrowText,
+			lexer.TokenHashArrow, lexer.TokenHashArrowText, lexer.TokenHashMinus:
+			op := p.curToken.Type
+			p.nextToken()
+			right, err := p.parseUnaryExpr()
+			if err != nil {
+				return nil, err
+			}
+			left = &BinaryExpr{Left: left, Op: op, Right: right}
+		default:
+			return left, nil
 		}
-		left = &BinaryExpr{Left: left, Op: op, Right: right}
 	}
-
-	return left, nil
 }
 
 func (p *Parser) parseUnaryExpr() (Expr, error) {
@@ -3017,10 +3077,11 @@ func (p *Parser) parseIdentOrFunction() (Expr, error) {
 		return p.parseFunctionCall(name)
 	}
 
-	// Check for table.column
+	// Check for table.column. KEY is a lexer keyword but SQLite also permits it
+	// as a column name, and jsonb_each's first output column is named key.
 	if p.curTokenIs(lexer.TokenDot) {
 		p.nextToken()
-		if !p.curTokenIs(lexer.TokenIdent) && !p.curTokenIs(lexer.TokenStar) {
+		if !p.curTokenIs(lexer.TokenIdent) && !p.curTokenIs(lexer.TokenStar) && !p.curTokenIs(lexer.TokenKEY) {
 			return nil, p.curError("expected column name after dot")
 		}
 		col := p.curToken.Literal

+ 13 - 0
pkg/pgerror/pgerror.go

@@ -11,6 +11,7 @@ import (
 const (
 	CodeFeatureNotSupported  = "0A000"
 	CodeInvalidText          = "22P02"
+	CodeInvalidParameter     = "22023"
 	CodeDatatypeMismatch     = "42804"
 	CodeUniqueViolation      = "23505"
 	CodeForeignKeyViolation  = "23503"
@@ -42,6 +43,18 @@ func InvalidText(format string, args ...interface{}) error {
 	return fmt.Errorf("%w: %s", ErrInvalidText, fmt.Sprintf(format, args...))
 }
 
+// ErrInvalidParameter marks a well-formed value that is not valid for the
+// operation it is used in, such as jsonb_array_length() on a non-array or a
+// jsonb path element that is NULL. Protocol layers map it to SQLSTATE 22023
+// (invalid_parameter_value).
+var ErrInvalidParameter = errors.New("invalid parameter value")
+
+// InvalidParameter returns an error that wraps ErrInvalidParameter with a
+// client-facing explanation of the rejected value.
+func InvalidParameter(format string, args ...interface{}) error {
+	return fmt.Errorf("%w: %s", ErrInvalidParameter, fmt.Sprintf(format, args...))
+}
+
 // ErrDatatypeMismatch marks a value whose type is wrong for the operation it is
 // used in, such as a non-integer array subscript. Protocol layers map it to
 // SQLSTATE 42804 (datatype_mismatch).

+ 40 - 0
pkg/pgserver/connection.go

@@ -432,6 +432,11 @@ func (c *Connection) handleQuery(msg *Message) error {
 				if c.txStatus == TxStatusInBlock {
 					c.txStatus = TxStatusFailed
 				}
+			} else if errors.Is(err, pgerror.ErrInvalidParameter) {
+				code = ErrCodeInvalidParameter
+				if c.txStatus == TxStatusInBlock {
+					c.txStatus = TxStatusFailed
+				}
 			} else if errors.Is(err, pgerror.ErrDatatypeMismatch) {
 				code = ErrCodeDatatypeMismatch
 				if c.txStatus == TxStatusInBlock {
@@ -604,6 +609,9 @@ func (c *Connection) handleBind(msg *Message) error {
 		if errors.Is(err, pgerror.ErrInvalidText) {
 			code = ErrCodeInvalidText
 		}
+		if errors.Is(err, pgerror.ErrInvalidParameter) {
+			code = ErrCodeInvalidParameter
+		}
 		return c.failExtended(code, err)
 	}
 	c.portals[portalName] = &portal{query: query}
@@ -714,6 +722,11 @@ func (c *Connection) handleExecute(msg *Message) error {
 			if c.txStatus == TxStatusInBlock {
 				c.txStatus = TxStatusFailed
 			}
+		} else if errors.Is(err, pgerror.ErrInvalidParameter) {
+			code = ErrCodeInvalidParameter
+			if c.txStatus == TxStatusInBlock {
+				c.txStatus = TxStatusFailed
+			}
 		} else if errors.Is(err, pgerror.ErrDatatypeMismatch) {
 			code = ErrCodeDatatypeMismatch
 			if c.txStatus == TxStatusInBlock {
@@ -1172,6 +1185,21 @@ func parameterLiteral(param boundParameter) (string, error) {
 		if _, isArray := arrayParamElementType(param.oid); isArray {
 			return "", fmt.Errorf("%w: binary array parameter format for OID %d; send arrays in text format", pgerror.ErrFeatureNotSupported, param.oid)
 		}
+		if param.oid == 3802 {
+			// 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:
 			if len(param.value) != 1 {
@@ -1201,6 +1229,16 @@ func parameterLiteral(param boundParameter) (string, error) {
 		}
 	}
 	value := string(param.value)
+	if param.oid == 3802 {
+		// A text-format jsonb parameter is canonicalized at Bind, so the executor
+		// receives the same bytes a jsonb column stores and an invalid document
+		// fails before execution.
+		canonical, err := executor.CanonicalizeJSONB(value)
+		if err != nil {
+			return "", err
+		}
+		return "'" + strings.ReplaceAll(canonical, "'", "''") + "'::jsonb", nil
+	}
 	if element, isArray := arrayParamElementType(param.oid); isArray {
 		// The bound value is PostgreSQL array text. Validate and canonicalize it
 		// now so a malformed array fails at Bind instead of during row
@@ -1590,6 +1628,8 @@ func (c *Connection) getOIDForType(typeName string) int32 {
 		// Values round-trip through JSON as float64; there is no exact decimal
 		// carrier, so NUMERIC is not advertised.
 		return 701 // FLOAT8OID
+	case "JSONB":
+		return 3802 // JSONBOID
 	case "TEXT", "VARCHAR", "CHAR", "UUID":
 		// UUID is stored as text (SQLite-style), so it reports TEXTOID rather
 		// than the native PG UUID OID.

+ 6 - 4
pkg/pgserver/json_contract_test.go

@@ -32,9 +32,10 @@ func jsonErrorField(t *testing.T, msg *Message, want byte) string {
 	return ""
 }
 
-// The supported JSON contract is TEXT storage plus the JSON1 function surface
-// and the -> / ->> operators. JSON/JSONB type names are accepted as
-// text-affinity aliases; there is no native binary jsonb type.
+// The JSON (text) contract is TEXT storage plus the JSON1 function surface and
+// the -> / ->> operators. The JSONB type is native: it stores canonical JSON
+// text and reports OID 3802 (see jsonb_contract_test.go), while JSON keeps the
+// text-backed JSON1 behavior exercised here.
 func TestJSONTextContractOverSimpleQuery(t *testing.T) {
 	c, client := newDataConnection(t)
 	runQuery(t, c, client, &Message{Type: MsgQuery, Data: append([]byte("CREATE TABLE settings (id INTEGER PRIMARY KEY, body TEXT)"), 0)})
@@ -55,7 +56,8 @@ func TestJSONTextContractOverSimpleQuery(t *testing.T) {
 		t.Fatalf("json partial update = %v, want [light 2]", values)
 	}
 
-	// JSON and JSONB column type names are text-affinity aliases.
+	// A JSON column keeps the text-backed JSON1 behavior (native JSONB is
+	// covered separately).
 	runQuery(t, c, client, &Message{Type: MsgQuery, Data: append([]byte("CREATE TABLE docs (id INTEGER PRIMARY KEY, body JSON)"), 0)})
 	runQuery(t, c, client, &Message{Type: MsgQuery, Data: append([]byte(`INSERT INTO docs (id, body) VALUES (1, '{"a":1}')`), 0)})
 	msgs = runQuery(t, c, client, &Message{Type: MsgQuery, Data: append([]byte(`SELECT json_extract(body, '$.a') FROM docs`), 0)})

+ 173 - 0
pkg/pgserver/jsonb_contract_test.go

@@ -0,0 +1,173 @@
+package pgserver
+
+import (
+	"testing"
+)
+
+// runExtendedJSONBBind drives one extended-protocol cycle with a single
+// parameter of the given OID, format, and raw value.
+func runExtendedJSONBBind(t *testing.T, c *Connection, conn *countingConn, sql string, oid int32, format int16, value []byte) []*Message {
+	t.Helper()
+	parse := NewMessageBuilder()
+	parse.WriteString("s1")
+	parse.WriteString(sql)
+	parse.WriteInt16(1)
+	parse.WriteInt32(oid)
+
+	bind := NewMessageBuilder()
+	bind.WriteString("p1")
+	bind.WriteString("s1")
+	bind.WriteInt16(1) // one parameter format
+	bind.WriteInt16(format)
+	bind.WriteInt16(1) // one parameter
+	bind.WriteInt32(int32(len(value)))
+	bind.WriteBytes(value)
+	bind.WriteInt16(0) // all-text result formats
+
+	execute := NewMessageBuilder()
+	execute.WriteString("p1")
+	execute.WriteInt32(0)
+
+	for _, msg := range []*Message{
+		{Type: MsgParse, Data: parse.Bytes()},
+		{Type: MsgBind, Data: bind.Bytes()},
+		{Type: MsgExecute, Data: execute.Bytes()},
+		{Type: MsgSync},
+	} {
+		if err := c.handleMessage(msg); err != nil {
+			t.Fatalf("handle %c: %v", msg.Type, err)
+		}
+	}
+	return readAllMessages(t, conn.bytes())
+}
+
+// A text-format jsonb parameter (OID 3802) is canonicalized at Bind, so the
+// query sees canonical jsonb bytes and an invalid document fails before
+// execution.
+func TestJSONBTextParameterOverExtendedQuery(t *testing.T) {
+	c, conn := arrayConnection(t)
+
+	conn.reset()
+	messages := runExtendedJSONBBind(t, c, conn, `SELECT $1`, 3802, 0, []byte(`{ "b" : 2, "a" : 1.50 }`))
+	if errMsg := findError(messages); errMsg != nil {
+		t.Fatalf("jsonb parameter failed: %s %s", errorCode(errMsg), jsonErrorField(t, errMsg, ErrorFieldMessage))
+	}
+	if row := firstDataRow(t, messages); len(row) != 1 || row[0] != `{"a":1.5,"b":2}` {
+		t.Fatalf("canonical jsonb parameter = %v", row)
+	}
+	if oids := rowDescriptionOIDs(t, findRowDescription(t, messages).Data); len(oids) != 1 || oids[0] != 3802 {
+		t.Fatalf("jsonb parameter result OIDs = %v, want [3802]", oids)
+	}
+
+	// The bound value is a real jsonb value for operators and functions.
+	conn.reset()
+	messages = runExtendedJSONBBind(t, c, conn, `SELECT $1 -> 'a', $1 ? 'b'`, 3802, 0, []byte(`{"b":2,"a":1}`))
+	if errMsg := findError(messages); errMsg != nil {
+		t.Fatalf("jsonb operator over parameter failed: %s", errorCode(errMsg))
+	}
+	if row := firstDataRow(t, messages); len(row) != 2 || row[0] != "1" || row[1] != "true" {
+		t.Fatalf("jsonb operators over parameter = %v", row)
+	}
+
+	// Invalid text is 22P02 at Bind.
+	conn.reset()
+	messages = runExtendedJSONBBind(t, c, conn, `SELECT $1`, 3802, 0, []byte(`not json`))
+	errMsg := findError(messages)
+	if errMsg == nil || errorCode(errMsg) != ErrCodeInvalidText {
+		t.Fatalf("invalid jsonb parameter error = %v, want 22P02", messages)
+	}
+
+	// An INSERT binds the canonical document into the JSONB column, and a later
+	// read returns the same canonical bytes.
+	conn.reset()
+	if _, err := c.executor.Execute(parseStmt(t, `CREATE TABLE docs (id INTEGER PRIMARY KEY, doc JSONB)`)); err != nil {
+		t.Fatal(err)
+	}
+	messages = runExtendedJSONBBind(t, c, conn, `INSERT INTO docs VALUES (1, $1)`, 3802, 0, []byte(`{ "b" : 2, "a" : 1.50 }`))
+	if errMsg := findError(messages); errMsg != nil {
+		t.Fatalf("jsonb INSERT parameter failed: %s", errorCode(errMsg))
+	}
+	conn.reset()
+	messages = runSimple(t, c, conn, `SELECT doc FROM docs`)
+	if row := firstDataRow(t, messages); len(row) != 1 || row[0] != `{"a":1.5,"b":2}` {
+		t.Fatalf("jsonb INSERT round trip = %v", row)
+	}
+
+	// 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 -> '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)
+		}
+	}
+}
+
+// JSONB columns report jsonb metadata (OID 3802) while JSON columns keep their
+// text-backed behavior (OID 25).
+func TestJSONBColumnTypeOIDs(t *testing.T) {
+	c, conn := arrayConnection(t)
+	for _, sql := range []string{
+		`CREATE TABLE docs (id INTEGER PRIMARY KEY, doc JSONB, body JSON)`,
+		`INSERT INTO docs VALUES (1, '{"b":2, "a":1}', '{"b":2, "a":1}')`,
+	} {
+		if _, err := c.executor.Execute(parseStmt(t, sql)); err != nil {
+			t.Fatalf("%s: %v", sql, err)
+		}
+	}
+
+	conn.reset()
+	messages := runSimple(t, c, conn, `SELECT doc, body, to_jsonb(1), doc -> 'a' FROM docs`)
+	if errMsg := findError(messages); errMsg != nil {
+		t.Fatalf("select failed: %s", errorCode(errMsg))
+	}
+	oids := rowDescriptionOIDs(t, findRowDescription(t, messages).Data)
+	want := []int32{3802, 25, 3802, 3802}
+	if len(oids) != len(want) {
+		t.Fatalf("jsonb/json projection OIDs = %v, want %v", oids, want)
+	}
+	for i := range want {
+		if oids[i] != want[i] {
+			t.Fatalf("column %d OID = %d, want %d (all: %v)", i, oids[i], want[i], oids)
+		}
+	}
+	if row := firstDataRow(t, messages); len(row) != 4 || row[0] != `{"a":1,"b":2}` || row[1] != `{"b":2, "a":1}` || row[3] != `1` {
+		t.Fatalf("jsonb/json projection values = %v", row)
+	}
+
+	// Text-format parameters for JSON (114) keep their previous passthrough
+	// behavior rather than being canonicalized.
+	conn.reset()
+	messages = runExtendedJSONBBind(t, c, conn, `SELECT $1`, 114, 0, []byte(`{ "b" : 1 }`))
+	if errMsg := findError(messages); errMsg != nil {
+		t.Fatalf("JSON parameter failed: %s", errorCode(errMsg))
+	}
+	if row := firstDataRow(t, messages); len(row) != 1 || row[0] != `{ "b" : 1 }` {
+		t.Fatalf("JSON parameter value = %v, want the original text", row)
+	}
+}

+ 74 - 0
pkg/storage/jsonb_test.go

@@ -0,0 +1,74 @@
+package storage
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/danfragoso/pizzasql-next/pkg/analyzer"
+)
+
+// A JSONB column is persisted as the type name the catalog already stores for
+// any column, with no row-format change: the canonical document is an ordinary
+// text value and survives a manager reload byte-for-byte.
+func TestJSONBColumnTypeSurvivesReload(t *testing.T) {
+	_, pool, schemas, tables := newTestSession(t)
+	if err := schemas.CreateTable(&Schema{
+		Name: "docs",
+		Columns: []Column{
+			{Name: "id", Type: "INTEGER", PrimaryKey: true, Nullable: false},
+			{Name: "doc", Type: "JSONB", Nullable: true},
+		},
+	}); err != nil {
+		t.Fatalf("create table: %v", err)
+	}
+
+	canonical := `{"a":1,"b":2}`
+	if err := tables.Insert("docs", Row{"id": int64(1), "doc": canonical}); err != nil {
+		t.Fatalf("insert: %v", err)
+	}
+
+	schema, err := schemas.GetSchema("docs")
+	if err != nil {
+		t.Fatalf("get schema: %v", err)
+	}
+	if len(schema.Columns) != 2 || schema.Columns[1].Type != "JSONB" {
+		t.Fatalf("declared columns = %+v, want doc JSONB", schema.Columns)
+	}
+	info := schema.ToAnalyzerTableInfo()
+	if len(info.Columns) != 2 || info.Columns[1].Type != analyzer.TypeJSONB {
+		t.Fatalf("analyzer column types = %+v, want doc JSONB", info.Columns)
+	}
+	if info.Columns[1].Array {
+		t.Fatal("a JSONB column must not be marked as an array")
+	}
+
+	// The durable catalog stores the type name and the row value unchanged.
+	var catalog string
+	if err := pool.WithClient(func(c *KVClient) error {
+		var err error
+		catalog, err = c.Read("testdb:_schema:docs")
+		return err
+	}); err != nil {
+		t.Fatalf("read catalog: %v", err)
+	}
+	if !strings.Contains(catalog, `"type":"JSONB"`) {
+		t.Fatalf("catalog = %s, want a JSONB column type", catalog)
+	}
+
+	reloadedSchemas := NewSchemaManager(pool, "testdb")
+	reloadedTables := NewTableManager(pool, reloadedSchemas, "testdb")
+	reloadedSchema, err := reloadedSchemas.GetSchema("docs")
+	if err != nil {
+		t.Fatalf("reload schema: %v", err)
+	}
+	if reloadedSchema.Columns[1].Type != "JSONB" {
+		t.Fatalf("reloaded type = %q, want JSONB", reloadedSchema.Columns[1].Type)
+	}
+	rows, err := reloadedTables.Select("docs", nil)
+	if err != nil || len(rows) != 1 {
+		t.Fatalf("reloaded rows = %v, err %v", rows, err)
+	}
+	if rows[0]["doc"] != canonical {
+		t.Fatalf("reloaded doc = %v, want %q", rows[0]["doc"], canonical)
+	}
+}