Przeglądaj źródła

feat: implement -> and ->> JSON operators

Add the SQLite/PostgreSQL arrow operators for JSON text documents:

- `json -> key` returns the selected member or array element as JSON text
  (strings keep their quotes, objects/arrays are re-serialized).
- `json ->> key` returns the SQL value: text, number, 1/0 for booleans, and
  SQL NULL for JSON null.
- The right operand is either an object label, an array index (negative
  indexes count from the end), or a `$` path; chaining is left-associative.
- Missing paths, NULL documents, and NULL keys return SQL NULL. Malformed
  JSON raises the same error json_extract() raises.

Precedence: the operators sit between multiplicative and unary operators, so
they bind tighter than *, /, %, +, -, ||, bitwise, and comparison operators,
and looser than unary -, +, and ~. `a -> 'x' || 'y'` concatenates the
extracted value and `a -> 'x' -> 0` indexes the extracted member.

Update the JSON capability document to advertise the operators and replace
the pgserver contract test that asserted they were rejected with wire-level
coverage.
Danilo Fragoso 1 dzień temu
rodzic
commit
2a7206f73a

+ 4 - 0
pkg/analyzer/analyzer.go

@@ -994,6 +994,10 @@ func (a *Analyzer) analyzeBinaryExpr(e *parser.BinaryExpr) (*ExprInfo, error) {
 	case lexer.TokenConcat:
 		// String concatenation
 		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).
+		info.Type = TypeAny
 	default:
 		info.Type = TypeUnknown
 	}

+ 2 - 2
pkg/capabilities/capabilities.go

@@ -217,8 +217,8 @@ func Current() *Document {
 		JSON: JSON{
 			Storage:        "TEXT (JSON and JSONB type names are accepted without native semantics; insert JSON as text)",
 			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: false, Status: "rejected", SQLState: "42601", Notes: "-> and ->> are not implemented; use json_extract"},
-			InvalidInput:   "json_extract/json_set throw 'malformed JSON'; json_valid returns 0",
+			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",
 		},
 
 		ErrorCodes: map[string]string{

+ 13 - 0
pkg/capabilities/capabilities_test.go

@@ -40,3 +40,16 @@ func TestCurrentDocumentIsVersionedAndMarshalable(t *testing.T) {
 		t.Fatalf("unexpected notification limits: %+v", round.Notifications)
 	}
 }
+
+func TestJSONArrowOperatorsAreAdvertised(t *testing.T) {
+	arrow := Current().JSON.ArrowOperators
+	if !arrow.Supported {
+		t.Fatal("-> and ->> should be advertised as supported")
+	}
+	if arrow.SQLState != "" {
+		t.Fatalf("arrow operator SQLSTATE = %q, want none", arrow.SQLState)
+	}
+	if arrow.Status == "rejected" {
+		t.Fatalf("arrow operator status = %q, want a supported status", arrow.Status)
+	}
+}

+ 7 - 0
pkg/executor/executor.go

@@ -4943,6 +4943,10 @@ func (e *Executor) evalBinaryExpr(expr *parser.BinaryExpr, row storage.Row) (int
 		return applyBitwise(expr.Op, left, right)
 	}
 
+	if expr.Op == lexer.TokenArrow || expr.Op == lexer.TokenArrowText {
+		return jsonArrowExtract(left, right, expr.Op == lexer.TokenArrowText)
+	}
+
 	switch expr.Op {
 	case lexer.TokenPlus:
 		if left == nil || right == nil {
@@ -5066,6 +5070,9 @@ func (e *Executor) applyBinaryOp(op lexer.TokenType, left, right interface{}) (i
 		}
 		return applyBitwise(op, left, right)
 	}
+	if op == lexer.TokenArrow || op == lexer.TokenArrowText {
+		return jsonArrowExtract(left, right, op == lexer.TokenArrowText)
+	}
 	switch op {
 	case lexer.TokenPlus:
 		if left == nil || right == nil {

+ 57 - 0
pkg/executor/json.go

@@ -329,6 +329,63 @@ func newJSONContainer(seg jsonPathSeg) interface{} {
 	return map[string]interface{}{}
 }
 
+// jsonArrowExtract evaluates the SQLite -> and ->> operators. The key operand is
+// a field label, an array index, or a $ path. A NULL operand or a path that
+// selects nothing yields SQL NULL; malformed JSON in the document is an error,
+// as it is for json_extract(). scalar selects ->> (SQL value of the selected
+// node) instead of -> (JSON text for the selected node).
+func jsonArrowExtract(document, key interface{}, scalar bool) (interface{}, error) {
+	if document == nil || key == nil {
+		return nil, nil
+	}
+	node, err := jsonInput(document)
+	if err != nil {
+		return nil, err
+	}
+	segs, err := jsonArrowPath(key)
+	if err != nil {
+		return nil, err
+	}
+	found, ok := jsonLookup(node, segs)
+	if !ok {
+		return nil, nil
+	}
+	if scalar {
+		return jsonNodeToSQL(found), nil
+	}
+	s, err := jsonEncode(found)
+	if err != nil {
+		return nil, err
+	}
+	return jsonText(s), nil
+}
+
+// jsonArrowPath turns an arrow-operator operand into a path. Text that starts
+// with $ is a JSON path; other text names a single object member, so labels
+// containing dots or brackets are not reinterpreted as paths. Integers select
+// an array position, with negative values counting from the end.
+func jsonArrowPath(key interface{}) ([]jsonPathSeg, error) {
+	switch t := key.(type) {
+	case string:
+		return jsonArrowPathText(t)
+	case jsonText:
+		return jsonArrowPathText(string(t))
+	case int64:
+		return []jsonPathSeg{{isIndex: true, index: int(t), fromEnd: t < 0}}, nil
+	case int:
+		return []jsonPathSeg{{isIndex: true, index: t, fromEnd: t < 0}}, nil
+	default:
+		return nil, fmt.Errorf("JSON path error")
+	}
+}
+
+func jsonArrowPathText(path string) ([]jsonPathSeg, error) {
+	if strings.HasPrefix(path, "$") {
+		return parseJSONPath(path)
+	}
+	return []jsonPathSeg{{key: path}}, nil
+}
+
 // jsonTypeName returns the JSON1 type name for a decoded node.
 func jsonTypeName(v interface{}) string {
 	switch v.(type) {

+ 100 - 0
pkg/executor/json_operator_test.go

@@ -0,0 +1,100 @@
+package executor
+
+import (
+	"strings"
+	"testing"
+)
+
+func TestJSONArrowObjectAndArrayAccess(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+
+	tests := []struct {
+		name string
+		sql  string
+		want interface{}
+	}{
+		{"object member as JSON text", `SELECT '{"a":1,"s":"x"}' -> 'a'`, "1"},
+		{"string member keeps JSON quotes", `SELECT '{"a":1,"s":"x"}' -> 's'`, `"x"`},
+		{"string member as SQL text", `SELECT '{"a":1,"s":"x"}' ->> 's'`, "x"},
+		{"object member as JSON text", `SELECT '{"b":[10,20]}' -> 'b'`, "[10,20]"},
+		{"array element as JSON text", `SELECT '[10,20,30]' -> 1`, "20"},
+		{"array element as SQL number", `SELECT '[10,20,30]' ->> 1`, int64(20)},
+		{"array element as JSON text", `SELECT '["a","b"]' -> 0`, `"a"`},
+		{"negative array index", `SELECT '[10,20,30]' ->> -1`, int64(30)},
+		{"nested chaining", `SELECT '{"a":{"b":[{"c":7}]}}' -> 'a' -> 'b' -> 0 ->> 'c'`, int64(7)},
+		{"numeric key selects an array index", `SELECT '[10,20]' -> 1`, "20"},
+		{"string key names an object member", `SELECT '{"1":"one"}' -> '1'`, `"one"`},
+		{"numeric key on an object is missing", `SELECT '{"1":"one"}' -> 1`, nil},
+		{"json null as JSON text", `SELECT '{"a":null}' -> 'a'`, "null"},
+		{"json null as SQL NULL", `SELECT '{"a":null}' ->> 'a'`, nil},
+		{"json true as JSON text", `SELECT '{"a":true}' -> 'a'`, "true"},
+		{"json true as SQL integer", `SELECT '{"a":true}' ->> 'a'`, int64(1)},
+		{"json false as SQL integer", `SELECT '{"a":false}' ->> 'a'`, int64(0)},
+		{"path-style key", `SELECT '{"a":{"b":2}}' -> '$.a' ->> 'b'`, int64(2)},
+		{"whole document path", `SELECT '{"a":1}' ->> '$'`, `{"a":1}`},
+		{"missing object key", `SELECT '{"a":1}' -> 'b'`, nil},
+		{"missing object key as SQL", `SELECT '{"a":1}' ->> 'b'`, nil},
+		{"array index past the end", `SELECT '[1,2]' -> 5`, nil},
+		{"NULL document", `SELECT NULL -> 'a'`, nil},
+		{"NULL document as SQL", `SELECT NULL ->> 'a'`, nil},
+		{"NULL key", `SELECT '{"a":1}' -> NULL`, nil},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			res := execMust(t, e, tt.sql)
+			got := res.Rows[0][0]
+			if got != tt.want {
+				t.Fatalf("%s = %v (%T), want %v (%T)", tt.sql, got, got, tt.want, tt.want)
+			}
+		})
+	}
+}
+
+func TestJSONArrowMalformedInputIsAnError(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+
+	for _, sql := range []string{
+		`SELECT 'not json' -> 'a'`,
+		`SELECT 'not json' ->> 'a'`,
+		`SELECT '{"a":1}' -> '$.['`,
+	} {
+		if _, err := execSQL(e, sql); err == nil {
+			t.Fatalf("%s: expected an error", sql)
+		} else if !strings.Contains(err.Error(), "JSON") {
+			t.Fatalf("%s: unexpected error: %v", sql, err)
+		}
+	}
+}
+
+func TestJSONArrowInWhereOrderByAndUpdate(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE docs (id INTEGER PRIMARY KEY, doc TEXT, v TEXT)")
+	execMust(t, e, `INSERT INTO docs VALUES (1, '{"kind":"a","n":1}', NULL)`)
+	execMust(t, e, `INSERT INTO docs VALUES (2, '{"kind":"b","n":2}', NULL)`)
+	execMust(t, e, `INSERT INTO docs VALUES (3, '{"kind":"c","n":3}', NULL)`)
+
+	res := execMust(t, e, `SELECT id FROM docs WHERE doc ->> 'kind' = 'b'`)
+	if res.RowCount != 1 || res.Rows[0][0] != int64(2) {
+		t.Fatalf("WHERE doc ->> 'kind' = 'b' rows = %v, want id 2", res.Rows)
+	}
+
+	res = execMust(t, e, `SELECT doc ->> 'kind' AS kind FROM docs ORDER BY doc -> 'n' DESC`)
+	if res.RowCount != 3 {
+		t.Fatalf("ORDER BY rows = %d, want 3", res.RowCount)
+	}
+	if res.Rows[0][0] != "c" || res.Rows[1][0] != "b" || res.Rows[2][0] != "a" {
+		t.Fatalf("ORDER BY doc -> 'n' DESC = %v, want c, b, a", res.Rows)
+	}
+
+	execMust(t, e, `UPDATE docs SET v = doc ->> 'kind'`)
+	res = execMust(t, e, `SELECT id, v FROM docs ORDER BY id`)
+	for i, want := range []string{"a", "b", "c"} {
+		if res.Rows[i][1] != want {
+			t.Fatalf("row %d v = %v, want %q", i, res.Rows[i][1], want)
+		}
+	}
+}

+ 11 - 0
pkg/lexer/lexer.go

@@ -171,6 +171,17 @@ func (l *Lexer) NextToken() Token {
 		if l.peekChar() == '-' {
 			// Line comment
 			tok = l.readLineComment()
+		} else if l.peekChar() == '>' {
+			l.readChar() // consume '-'
+			if l.peekChar() == '>' {
+				l.readChar() // consume the first '>'
+				tok.Type = TokenArrowText
+				tok.Literal = "->>"
+			} else {
+				tok.Type = TokenArrow
+				tok.Literal = "->"
+			}
+			l.readChar()
 		} else {
 			tok.Type = TokenMinus
 			tok.Literal = "-"

+ 54 - 0
pkg/lexer/lexer_test.go

@@ -487,6 +487,60 @@ func TestLexerBlobLiteralWhitespaceAndErrors(t *testing.T) {
 	}
 }
 
+func TestLexerJSONArrowOperators(t *testing.T) {
+	tests := []struct {
+		input   string
+		literal string
+	}{
+		{"->", "->"},
+		{"->>", "->>"},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.input, func(t *testing.T) {
+			tok := New(tt.input).NextToken()
+			want := TokenArrow
+			if tt.input == "->>" {
+				want = TokenArrowText
+			}
+			if tok.Type != want {
+				t.Fatalf("type = %v, want %v", tok.Type, want)
+			}
+			if tok.Literal != tt.literal {
+				t.Fatalf("literal = %q, want %q", tok.Literal, tt.literal)
+			}
+		})
+	}
+}
+
+func TestLexerJSONArrowOperatorsDoNotChangeMinusOrComments(t *testing.T) {
+	tests := []struct {
+		input    string
+		expected []TokenType
+	}{
+		{"1-2", []TokenType{TokenNumber, TokenMinus, TokenNumber, TokenEOF}},
+		{"-3", []TokenType{TokenMinus, TokenNumber, TokenEOF}},
+		{"-- comment", []TokenType{TokenComment, TokenEOF}},
+		{"a->'b'", []TokenType{TokenIdent, TokenArrow, TokenString, TokenEOF}},
+		{"a->>'b'", []TokenType{TokenIdent, TokenArrowText, TokenString, TokenEOF}},
+		{"a -> 'b' ->> 0", []TokenType{TokenIdent, TokenArrow, TokenString, TokenArrowText, TokenNumber, TokenEOF}},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.input, func(t *testing.T) {
+			tokens := New(tt.input).Tokenize()
+			if len(tokens) != len(tt.expected) {
+				t.Fatalf("expected %d tokens, got %d: %v", len(tt.expected), len(tokens), tokens)
+			}
+			for i, want := range tt.expected {
+				if tokens[i].Type != want {
+					t.Errorf("token[%d] = %v, want %v", i, tokens[i].Type, want)
+				}
+			}
+		})
+	}
+}
+
 func BenchmarkLexer(b *testing.B) {
 	input := `
 		SELECT u.id, u.name, u.email, COUNT(o.id) as order_count

+ 4 - 0
pkg/lexer/token.go

@@ -23,6 +23,8 @@ const (
 	TokenSlash      // /
 	TokenPercent    // %
 	TokenConcat     // ||
+	TokenArrow      // ->
+	TokenArrowText  // ->>
 	TokenEq         // =
 	TokenNeq        // <> or !=
 	TokenLt         // <
@@ -402,6 +404,8 @@ var tokenNames = map[TokenType]string{
 	TokenSlash:      "/",
 	TokenPercent:    "%",
 	TokenConcat:     "||",
+	TokenArrow:      "->",
+	TokenArrowText:  "->>",
 	TokenEq:         "=",
 	TokenNeq:        "<>",
 	TokenLt:         "<",

+ 4 - 0
pkg/parser/format.go

@@ -141,6 +141,10 @@ func operatorString(op lexer.TokenType) string {
 		return "%"
 	case lexer.TokenConcat:
 		return "||"
+	case lexer.TokenArrow:
+		return "->"
+	case lexer.TokenArrowText:
+		return "->>"
 	case lexer.TokenEq:
 		return "="
 	case lexer.TokenNeq:

+ 114 - 0
pkg/parser/json_operator_test.go

@@ -0,0 +1,114 @@
+package parser
+
+import (
+	"testing"
+
+	"github.com/danfragoso/pizzasql-next/pkg/lexer"
+)
+
+func TestParseJSONArrowChainingIsLeftAssociative(t *testing.T) {
+	expr := parseExpr(t, "doc -> 'a' -> 0")
+
+	outer, ok := expr.(*BinaryExpr)
+	if !ok {
+		t.Fatalf("expected *BinaryExpr, got %T", expr)
+	}
+	if outer.Op != lexer.TokenArrow {
+		t.Fatalf("outer operator = %v, want ->", outer.Op)
+	}
+	lit, ok := outer.Right.(*LiteralExpr)
+	if !ok || lit.Value != "0" {
+		t.Fatalf("outer right = %#v, want literal 0", outer.Right)
+	}
+
+	inner, ok := outer.Left.(*BinaryExpr)
+	if !ok {
+		t.Fatalf("outer left = %T, want *BinaryExpr", outer.Left)
+	}
+	if inner.Op != lexer.TokenArrow {
+		t.Fatalf("inner operator = %v, want ->", inner.Op)
+	}
+	ref, ok := inner.Left.(*ColumnRef)
+	if !ok || ref.Table != "" || ref.Column != "doc" {
+		t.Fatalf("inner left = %#v, want column doc", inner.Left)
+	}
+	key, ok := inner.Right.(*LiteralExpr)
+	if !ok || key.Value != "a" {
+		t.Fatalf("inner right = %#v, want literal 'a'", inner.Right)
+	}
+}
+
+func TestParseJSONArrowPrecedence(t *testing.T) {
+	tests := []struct {
+		name    string
+		input   string
+		root    lexer.TokenType
+		childOp lexer.TokenType
+	}{
+		{"concatenation binds looser", "doc -> 'a' || 'x'", lexer.TokenConcat, lexer.TokenArrow},
+		{"addition binds looser", "doc -> 'a' + 1", lexer.TokenPlus, lexer.TokenArrow},
+		{"multiplication binds looser", "doc ->> 'a' * 2", lexer.TokenStar, lexer.TokenArrowText},
+		{"bitwise binds looser", "doc -> 'a' & 1", lexer.TokenBitAnd, lexer.TokenArrow},
+		{"comparison binds looser", "doc ->> 'a' = 'x'", lexer.TokenEq, lexer.TokenArrowText},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			expr := parseExpr(t, tt.input)
+			root, ok := expr.(*BinaryExpr)
+			if !ok {
+				t.Fatalf("expected *BinaryExpr, got %T", expr)
+			}
+			if root.Op != tt.root {
+				t.Fatalf("root operator = %v, want %v", root.Op, tt.root)
+			}
+			child, ok := root.Left.(*BinaryExpr)
+			if !ok {
+				t.Fatalf("root left = %T, want *BinaryExpr", root.Left)
+			}
+			if child.Op != tt.childOp {
+				t.Fatalf("left child operator = %v, want %v", child.Op, tt.childOp)
+			}
+		})
+	}
+}
+
+func TestParseJSONArrowBindsLooserThanUnary(t *testing.T) {
+	expr := parseExpr(t, "-doc -> 'a'")
+	arrow, ok := expr.(*BinaryExpr)
+	if !ok || arrow.Op != lexer.TokenArrow {
+		t.Fatalf("expected root ->, got %#v", expr)
+	}
+	neg, ok := arrow.Left.(*UnaryExpr)
+	if !ok || neg.Op != lexer.TokenMinus {
+		t.Fatalf("arrow left = %#v, want unary minus", arrow.Left)
+	}
+
+	// The right operand may be a negative array index.
+	expr = parseExpr(t, "doc -> -1")
+	arrow, ok = expr.(*BinaryExpr)
+	if !ok || arrow.Op != lexer.TokenArrow {
+		t.Fatalf("expected root ->, got %#v", expr)
+	}
+	neg, ok = arrow.Right.(*UnaryExpr)
+	if !ok || neg.Op != lexer.TokenMinus {
+		t.Fatalf("arrow right = %#v, want unary minus", arrow.Right)
+	}
+}
+
+func TestParseJSONArrowRespectsParentheses(t *testing.T) {
+	expr := parseExpr(t, "(doc -> 'a') ->> 'b'")
+
+	outer, ok := expr.(*BinaryExpr)
+	if !ok || outer.Op != lexer.TokenArrowText {
+		t.Fatalf("expected root ->>, got %#v", expr)
+	}
+	paren, ok := outer.Left.(*ParenExpr)
+	if !ok {
+		t.Fatalf("outer left = %T, want *ParenExpr", outer.Left)
+	}
+	inner, ok := paren.Expr.(*BinaryExpr)
+	if !ok || inner.Op != lexer.TokenArrow {
+		t.Fatalf("parenthesized expression = %#v, want ->", paren.Expr)
+	}
+}

+ 27 - 1
pkg/parser/parser.go

@@ -2562,12 +2562,38 @@ func (p *Parser) parseAddExpr() (Expr, error) {
 }
 
 func (p *Parser) parseMulExpr() (Expr, error) {
-	left, err := p.parseUnaryExpr()
+	left, err := p.parseJSONExpr()
 	if err != nil {
 		return nil, err
 	}
 
 	for p.curTokenIs(lexer.TokenStar) || p.curTokenIs(lexer.TokenSlash) || p.curTokenIs(lexer.TokenPercent) {
+		op := p.curToken.Type
+		p.nextToken()
+		right, err := p.parseJSONExpr()
+		if err != nil {
+			return nil, err
+		}
+		left = &BinaryExpr{Left: left, Op: op, Right: right}
+	}
+
+	return left, nil
+}
+
+// parseJSONExpr parses the JSON extraction operators -> and ->>.
+//
+// Precedence: they bind tighter than multiplicative, additive/concatenation,
+// bitwise, and comparison operators, and looser than unary -, +, and ~. So
+// "a -> 'x' || 'y'" concatenates the extracted value, "a -> 'x' = 1" compares
+// it, and "-a -> 'x'" negates a before extraction. They are left-associative,
+// so "a -> 'x' -> 0" extracts 'x' and then indexes the result at 0.
+func (p *Parser) parseJSONExpr() (Expr, error) {
+	left, err := p.parseUnaryExpr()
+	if err != nil {
+		return nil, err
+	}
+
+	for p.curTokenIs(lexer.TokenArrow) || p.curTokenIs(lexer.TokenArrowText) {
 		op := p.curToken.Type
 		p.nextToken()
 		right, err := p.parseUnaryExpr()

+ 20 - 7
pkg/pgserver/json_contract_test.go

@@ -32,9 +32,9 @@ func jsonErrorField(t *testing.T, msg *Message, want byte) string {
 	return ""
 }
 
-// The supported JSON contract is TEXT storage plus the JSON1 function surface.
-// JSON/JSONB type names are accepted as text-affinity aliases; there is no
-// native binary jsonb type or ->> operator.
+// 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.
 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)})
@@ -82,10 +82,23 @@ func TestJSONInvalidInputOverSimpleQuery(t *testing.T) {
 	}
 }
 
-func TestJSONArrowOperatorUnsupported(t *testing.T) {
+func TestJSONArrowOperatorsOverSimpleQuery(t *testing.T) {
 	c, client := newDataConnection(t)
-	msgs := runQuery(t, c, client, &Message{Type: MsgQuery, Data: append([]byte(`SELECT '{"a":1}' -> '$.a'`), 0)})
-	if len(msgs) == 0 || msgs[0].Type != MsgErrorResponse {
-		t.Fatalf("expected a parse error for the -> operator, got %v", msgs)
+	runQuery(t, c, client, &Message{Type: MsgQuery, Data: append([]byte("CREATE TABLE settings (id INTEGER PRIMARY KEY, body TEXT)"), 0)})
+	runQuery(t, c, client, &Message{Type: MsgQuery, Data: append([]byte(`INSERT INTO settings (id, body) VALUES (1, '{"theme":"dark","widgets":[1,2]}')`), 0)})
+
+	msgs := runQuery(t, c, client, &Message{Type: MsgQuery, Data: append([]byte(`SELECT body ->> 'theme', body -> 'widgets' -> 0 FROM settings WHERE id = 1`), 0)})
+	values := dataRowValues(t, msgs[1])
+	if len(values) != 2 || values[0] != "dark" || values[1] != "1" {
+		t.Fatalf("arrow operators = %v, want [dark 1]", values)
+	}
+
+	// Malformed JSON through an arrow operator is the same error json_extract throws.
+	msgs = runQuery(t, c, client, &Message{Type: MsgQuery, Data: append([]byte(`SELECT 'not json' ->> 'a'`), 0)})
+	if len(msgs) != 2 || msgs[0].Type != MsgErrorResponse {
+		t.Fatalf("expected ErrorResponse+ReadyForQuery, got %v", msgs)
+	}
+	if msg := jsonErrorField(t, msgs[0], ErrorFieldMessage); !strings.Contains(msg, "malformed JSON") {
+		t.Fatalf("error message = %q, want a malformed-JSON error", msg)
 	}
 }