Pārlūkot izejas kodu

support SRF column alias lists and array containment

A set-returning function in FROM accepts PostgreSQL's `AS alias(col, ...)`
form: unnest and the jsonb set-returning functions rename their output
columns for projections and WHERE, an alias without a list keeps the
default names, and a list with the wrong arity reports 42P10.

`@>` and `<@` now compare one-dimensional arrays element-wise instead of
falling into the jsonb path and reporting a misleading 42804. Each
contained element needs some containing element, duplicates are
irrelevant, NULL elements only match other NULL elements, an empty right
array is contained, and a NULL operand yields NULL. NUMERIC elements
compare exactly as elsewhere.

Fixes #156
Fixes #157
Danilo Fragoso 11 stundas atpakaļ
vecāks
revīzija
598b2983a1

+ 26 - 3
pkg/analyzer/analyzer.go

@@ -300,10 +300,22 @@ func (a *Analyzer) defineTableRef(ref parser.TableRef) error {
 				return err
 			}
 		}
+		columns := tableFunctionColumns(ref.Function.Name)
+		names := make([]string, len(columns))
+		for i, column := range columns {
+			names[i] = column.Name
+		}
+		renamed, err := ref.ApplyColumnAliases(names)
+		if err != nil {
+			return err
+		}
+		for i := range columns {
+			columns[i].Name = renamed[i]
+		}
 		a.scope.DefineTable(&TableInfo{
 			Name:    ref.Name,
 			Alias:   ref.Alias,
-			Columns: tableFunctionColumns(ref.Function.Name),
+			Columns: columns,
 		})
 		return nil
 	}
@@ -1167,8 +1179,19 @@ func (a *Analyzer) analyzeBinaryExpr(e *parser.BinaryExpr) (*ExprInfo, error) {
 			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.
+		// Array containment compares two arrays element-wise. A jsonb
+		// containment needs a jsonb or text operand on both sides; a number is
+		// not silently reinterpreted as JSON.
+		if left.Array || right.Array {
+			if !comparisonTypesCompatible(left, right) {
+				return nil, &AnalysisError{
+					Type:    ErrTypeMismatch,
+					Message: fmt.Sprintf("array containment requires two arrays, got %s and %s", left.Type, right.Type),
+				}
+			}
+			info.Type = TypeBoolean
+			return info, nil
+		}
 		if !jsonbTextOperand(left.Type) || !jsonbTextOperand(right.Type) {
 			return nil, &AnalysisError{
 				Type:    ErrTypeMismatch,

+ 44 - 0
pkg/analyzer/arrays_test.go

@@ -1,10 +1,12 @@
 package analyzer
 
 import (
+	"errors"
 	"strings"
 	"testing"
 
 	"github.com/danfragoso/pizzasql-next/pkg/parser"
+	"github.com/danfragoso/pizzasql-next/pkg/pgerror"
 )
 
 // arrayCatalog describes an array-typed table so expression analysis sees the
@@ -102,3 +104,45 @@ func TestUnnestFromDefinesRelation(t *testing.T) {
 		t.Fatalf("unnest FROM analysis: %v", err)
 	}
 }
+
+func TestTableFunctionColumnAliasesAnalyze(t *testing.T) {
+	a := New(arrayCatalog())
+	for _, sql := range []string{
+		`SELECT u.x FROM unnest('{1,2}'::INTEGER[]) AS u(x)`,
+		`SELECT x FROM unnest('{1,2}'::INTEGER[]) AS u(x) WHERE x > 1`,
+		`SELECT e.k, e.v FROM jsonb_each('{"a":1}') AS e(k, v)`,
+	} {
+		if err := a.Analyze(parse(t, sql)); err != nil {
+			t.Errorf("analyze %q: %v", sql, err)
+		}
+	}
+
+	// A column alias list must match the function's output arity.
+	err := a.Analyze(parse(t, `SELECT * FROM jsonb_each('{"a":1}') AS e(k)`))
+	if !errors.Is(err, pgerror.ErrInvalidColumnReference) {
+		t.Fatalf("arity error = %v, want ErrInvalidColumnReference", err)
+	}
+}
+
+func TestArrayContainmentAnalyzes(t *testing.T) {
+	a := New(arrayCatalog())
+	for _, sql := range []string{
+		`SELECT nums FROM documents WHERE nums @> ARRAY[1]`,
+		`SELECT nums FROM documents WHERE ARRAY[1] <@ nums`,
+		`SELECT nums FROM documents WHERE nums @> '{1}'`,
+		`SELECT tags FROM documents WHERE tags @> ARRAY['go']`,
+		`SELECT nums FROM documents WHERE nums @> NULL`,
+	} {
+		if err := a.Analyze(parse(t, sql)); err != nil {
+			t.Errorf("analyze %q: %v", sql, err)
+		}
+	}
+	for _, sql := range []string{
+		`SELECT nums FROM documents WHERE nums @> 5`,
+		`SELECT nums FROM documents WHERE 5 <@ nums`,
+	} {
+		if err := a.Analyze(parse(t, sql)); err == nil {
+			t.Errorf("analyze %q succeeded, want an array containment type error", sql)
+		}
+	}
+}

+ 2 - 2
pkg/capabilities/capabilities.go

@@ -231,7 +231,7 @@ func Current() *Document {
 			DateTime: true,
 			JSONText: true,
 			JSONB:    Feature{Supported: true, Status: "native", SQLState: "0A000", Notes: "JSONB is a native column type stored as canonical JSON text: object keys sorted, duplicate keys last-wins, no insignificant whitespace, and numbers normalized to exact decimals (1.50 becomes 1.5, 1e2 becomes 100); the canonical form is idempotent and stable across reloads; malformed jsonb input reports 22P02, equality is canonical-text equality (key order and number spelling do not matter), and JSON/TEXT columns keep their existing text-backed JSON1 behavior; literals, ::jsonb and CAST(x AS JSONB), to_jsonb(any), and text-format 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"},
+			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), unnest(array) as the single FROM table function, and @>/<@ containment between one-dimensional arrays are supported; containment compares elements with the element type's equality (NUMERIC exactly), ignores duplicates (each contained element needs some containing element), matches NULL elements only with other NULL elements, treats an empty right array as contained, and returns NULL when an operand is NULL; 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"},
 		},
 
@@ -278,7 +278,7 @@ func Current() *Document {
 				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"},
+				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; AS alias(col, ...) renames the function output columns (a list with the wrong arity reports 42P10), and an alias without a list keeps PostgreSQL's default names"},
 				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"},
 			},

+ 2 - 2
pkg/capabilities/capabilities_test.go

@@ -199,7 +199,7 @@ func TestArraysAreAdvertised(t *testing.T) {
 	if types.Status != "one_dimensional" {
 		t.Fatalf("types.arrays status = %q, want one_dimensional", types.Status)
 	}
-	for _, want := range []string{"TEXT[]", "INTEGER[]", "BOOLEAN[]", "ARRAY[", "array_length", "unnest", "0A000", "22P02"} {
+	for _, want := range []string{"TEXT[]", "INTEGER[]", "BOOLEAN[]", "ARRAY[", "array_length", "unnest", "@>", "<@", "0A000", "22P02"} {
 		if !strings.Contains(types.Notes, want) {
 			t.Errorf("types.arrays notes do not mention %q", want)
 		}
@@ -272,7 +272,7 @@ func TestJSONBIsAdvertised(t *testing.T) {
 	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"} {
+	for _, want := range []string{"jsonb_each", "jsonb_array_elements", "jsonb_object_keys", "AS alias(col, ...)", "42P10", "0A000"} {
 		if !strings.Contains(jsonb.SetReturning.Notes, want) {
 			t.Errorf("json.jsonb.set_returning notes do not mention %q", want)
 		}

+ 106 - 0
pkg/executor/arrays.go

@@ -943,6 +943,112 @@ func (e *Executor) canonicalizeArrayComparison(leftExpr, rightExpr parser.Expr,
 	return left, right, nil
 }
 
+// arrayContainmentType reports the array type used to compare @> and <@
+// operands. The left operand's declared type wins when both sides are arrays.
+func (e *Executor) arrayContainmentType(leftExpr, rightExpr parser.Expr) (string, bool) {
+	if typeName, ok := e.arrayTypeOf(leftExpr); ok {
+		return typeName, true
+	}
+	return e.arrayTypeOf(rightExpr)
+}
+
+// arrayContainment evaluates @> and <@ for one-dimensional arrays. Each element
+// of the contained array must match some element of the containing array, so
+// duplicates are irrelevant, as in PostgreSQL. A NULL operand yields NULL;
+// inside the arrays a NULL element only matches another NULL element.
+func (e *Executor) arrayContainment(op lexer.TokenType, arrayType string, leftExpr, rightExpr parser.Expr, left, right interface{}) (interface{}, error) {
+	if left == nil || right == nil {
+		return nil, nil
+	}
+	leftElements, err := e.parsedArray(leftExpr, left)
+	if err != nil {
+		return nil, fmt.Errorf("array containment requires an array value: %w", err)
+	}
+	rightElements, err := e.parsedArray(rightExpr, right)
+	if err != nil {
+		return nil, fmt.Errorf("array containment requires an array value: %w", err)
+	}
+	container, contained := leftElements, rightElements
+	if op == lexer.TokenLtAt {
+		container, contained = rightElements, leftElements
+	}
+	return arrayContainsElements(container, contained, arrayType)
+}
+
+// arrayContainsElements reports whether every element of contained matches some
+// element of container. Element equality follows the array's element type, and
+// a NULL element only matches another NULL element.
+func arrayContainsElements(container, contained []*string, arrayType string) (bool, error) {
+	kind, err := arrayElementKindOf(arrayType)
+	if err != nil {
+		return false, err
+	}
+	container, err = canonicalArrayElements(container, kind, arrayType)
+	if err != nil {
+		return false, err
+	}
+	contained, err = canonicalArrayElements(contained, kind, arrayType)
+	if err != nil {
+		return false, err
+	}
+	for _, candidate := range contained {
+		matched := false
+		for _, element := range container {
+			equal, err := arrayElementsEqual(element, candidate, kind, arrayType)
+			if err != nil {
+				return false, err
+			}
+			if equal {
+				matched = true
+				break
+			}
+		}
+		if !matched {
+			return false, nil
+		}
+	}
+	return true, nil
+}
+
+// canonicalArrayElements canonicalizes each element so containment compares
+// equivalent spellings equally. NUMERIC elements keep their exact text and are
+// compared numerically instead.
+func canonicalArrayElements(elements []*string, kind arrayElementKind, arrayType string) ([]*string, error) {
+	if kind == arrayElementNumeric {
+		return elements, nil
+	}
+	canonical := make([]*string, len(elements))
+	for i, element := range elements {
+		value, err := canonicalArrayElement(element, kind, arrayType)
+		if err != nil {
+			return nil, err
+		}
+		canonical[i] = value
+	}
+	return canonical, nil
+}
+
+// arrayElementsEqual compares two canonical array elements. A nil element only
+// equals another nil element; NUMERIC elements compare exactly.
+func arrayElementsEqual(left, right *string, kind arrayElementKind, arrayType string) (bool, error) {
+	if left == nil || right == nil {
+		return left == nil && right == nil, nil
+	}
+	if kind != arrayElementNumeric {
+		return *left == *right, nil
+	}
+	leftRat, leftOK := numericValueRat(*left)
+	rightRat, rightOK := numericValueRat(*right)
+	if !leftOK || !rightOK {
+		invalid := *left
+		if !leftOK {
+			invalid = *right
+		}
+		return false, pgerror.InvalidText("invalid input syntax for type %s: %q", arrayType, invalid)
+	}
+	return leftRat.Cmp(rightRat) == 0, nil
+}
+
 // evalArrayCast converts a value to an array type. The input must already be
 // array text (a literal, a parameter, or another array value); every element is
 // validated and re-encoded canonically for the target element type.

+ 99 - 0
pkg/executor/arrays_test.go

@@ -969,3 +969,102 @@ func TestViewArrayTyping(t *testing.T) {
 		}
 	}
 }
+
+func TestTableFunctionColumnAliasList(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+
+	res := execMust(t, e, `SELECT unnest FROM unnest(ARRAY[1,2,3]) AS t(unnest)`)
+	if len(res.Columns) != 1 || res.Columns[0] != "unnest" {
+		t.Fatalf("aliased unnest columns = %v, want [unnest]", res.Columns)
+	}
+	if res.RowCount != 3 || res.Rows[2][0] != int64(3) {
+		t.Fatalf("aliased unnest rows = %v", res.Rows)
+	}
+
+	res = execMust(t, e, `SELECT t.n FROM unnest(ARRAY[10,20,30]) AS t(n) WHERE n > 10 ORDER BY n`)
+	if res.RowCount != 2 || res.Rows[0][0] != int64(20) || res.Rows[1][0] != int64(30) {
+		t.Fatalf("aliased unnest WHERE rows = %v", res.Rows)
+	}
+	if len(res.ColumnTypes) != 1 || res.ColumnTypes[0] != "INTEGER" {
+		t.Fatalf("aliased unnest column type = %v, want INTEGER", res.ColumnTypes)
+	}
+
+	res = execMust(t, e, `SELECT n FROM unnest('{a,b}'::TEXT[]) AS t(n) WHERE n = 'b'`)
+	if res.RowCount != 1 || res.Rows[0][0] != "b" {
+		t.Fatalf("aliased text unnest rows = %v", res.Rows)
+	}
+
+	_, err := execSQL(e, `SELECT * FROM unnest(ARRAY[1]) AS t(a, b)`)
+	if !errors.Is(err, pgerror.ErrInvalidColumnReference) {
+		t.Fatalf("alias arity error = %v, want ErrInvalidColumnReference", err)
+	}
+}
+
+func TestArrayContainment(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, `CREATE TABLE t (
+		id INTEGER PRIMARY KEY,
+		nums INTEGER[],
+		tags TEXT[],
+		flags BOOLEAN[],
+		exact NUMERIC[]
+	)`)
+	execMust(t, e, `INSERT INTO t VALUES
+		(1, '{1,2,3}', '{a,b}', '{t,f}', '{1.10,3}'),
+		(2, '{1,NULL}', '{NULL,"x"}', NULL, NULL),
+		(3, '{}', '{}', '{}', '{}'),
+		(4, NULL, NULL, NULL, NULL)`)
+
+	cases := []struct {
+		sql  string
+		want interface{}
+	}{
+		// Each contained element needs some containing element; duplicates are
+		// irrelevant.
+		{`SELECT nums @> ARRAY[1] FROM t WHERE id = 1`, true},
+		{`SELECT nums @> ARRAY[3,1] FROM t WHERE id = 1`, true},
+		{`SELECT nums @> ARRAY[2,2] FROM t WHERE id = 1`, true},
+		{`SELECT nums @> ARRAY[4] FROM t WHERE id = 1`, false},
+		{`SELECT nums <@ ARRAY[3,2,1,5] FROM t WHERE id = 1`, true},
+		{`SELECT ARRAY[3,2,1,5] @> nums FROM t WHERE id = 1`, true},
+		// Empty arrays: the empty array is contained by everything.
+		{`SELECT nums @> ARRAY[]::INTEGER[] FROM t WHERE id = 1`, true},
+		{`SELECT nums @> ARRAY[1] FROM t WHERE id = 3`, false},
+		{`SELECT nums @> ARRAY[]::INTEGER[] FROM t WHERE id = 3`, true},
+		{`SELECT nums <@ ARRAY[1] FROM t WHERE id = 3`, true},
+		// NULL elements only match other NULL elements.
+		{`SELECT nums @> ARRAY[NULL]::INTEGER[] FROM t WHERE id = 2`, true},
+		{`SELECT nums @> ARRAY[1] FROM t WHERE id = 2`, true},
+		{`SELECT nums @> ARRAY[2] FROM t WHERE id = 2`, false},
+		{`SELECT nums @> ARRAY[1, NULL]::INTEGER[] FROM t WHERE id = 2`, true},
+		{`SELECT nums @> ARRAY[1, NULL]::INTEGER[] FROM t WHERE id = 1`, false},
+		// A NULL operand yields NULL.
+		{`SELECT nums @> NULL FROM t WHERE id = 1`, nil},
+		{`SELECT NULL::INTEGER[] @> nums FROM t WHERE id = 1`, nil},
+		{`SELECT nums @> ARRAY[1] FROM t WHERE id = 4`, nil},
+		// Other element types and a text array literal on the right.
+		{`SELECT tags @> ARRAY['a'] FROM t WHERE id = 1`, true},
+		{`SELECT tags @> ARRAY['c'] FROM t WHERE id = 1`, false},
+		{`SELECT tags @> '{a,b}' FROM t WHERE id = 1`, true},
+		{`SELECT tags @> '{a,b,c}' FROM t WHERE id = 1`, false},
+		{`SELECT '{1,2}' <@ nums FROM t WHERE id = 1`, true},
+		{`SELECT '{1,4}' <@ nums FROM t WHERE id = 1`, false},
+		{`SELECT tags @> ARRAY[NULL]::TEXT[] FROM t WHERE id = 2`, true},
+		{`SELECT flags @> ARRAY[TRUE] FROM t WHERE id = 1`, true},
+		{`SELECT flags @> ARRAY[TRUE] FROM t WHERE id = 3`, false},
+		// NUMERIC elements compare exactly, so 1.10 matches 1.1.
+		{`SELECT exact @> ARRAY[1.1]::NUMERIC[] FROM t WHERE id = 1`, true},
+	}
+	for _, tc := range cases {
+		res := execMust(t, e, tc.sql)
+		if res.RowCount != 1 || res.Rows[0][0] != tc.want {
+			t.Errorf("%s = %v, want %v", tc.sql, res.Rows, tc.want)
+		}
+	}
+
+	if _, err := execSQL(e, `SELECT nums @> 5 FROM t`); jsonbErrorCode(t, err) != pgerror.CodeDatatypeMismatch {
+		t.Fatalf("integer containment error = %v, want 42804", err)
+	}
+}

+ 10 - 3
pkg/executor/executor.go

@@ -1751,8 +1751,11 @@ func (e *Executor) executeSelectFromTableFunction(stmt *parser.SelectStmt) (*Res
 			rows = append(rows, []interface{}{typed})
 		}
 	}
-	column := "unnest"
-	result, err := e.executeSelectOnMaterialized(stmt, []string{column}, rows)
+	columns, err := ref.ApplyColumnAliases([]string{"unnest"})
+	if err != nil {
+		return nil, err
+	}
+	result, err := e.executeSelectOnMaterialized(stmt, columns, rows)
 	if err != nil {
 		return nil, err
 	}
@@ -1764,7 +1767,7 @@ func (e *Executor) executeSelectFromTableFunction(stmt *parser.SelectStmt) (*Res
 				continue
 			}
 			types = append(types, inferExprType(projection.Expr, func(_, columnName string) (string, bool) {
-				if strings.EqualFold(columnName, column) {
+				if strings.EqualFold(columnName, columns[0]) {
 					return elementType, true
 				}
 				return "", false
@@ -1791,6 +1794,10 @@ func (e *Executor) executeSelectFromJSONBTableFunction(stmt *parser.SelectStmt,
 	if err != nil {
 		return nil, err
 	}
+	columns, err = ref.ApplyColumnAliases(columns)
+	if err != nil {
+		return nil, err
+	}
 	qualifier := ref.Alias
 	if qualifier == "" {
 		qualifier = ref.Name

+ 9 - 0
pkg/executor/jsonb.go

@@ -491,6 +491,15 @@ func (e *Executor) evalJSONBBinary(op lexer.TokenType, leftExpr, rightExpr parse
 	leftJSONB := e.isJSONBExpr(leftExpr)
 	rightJSONB := e.isJSONBExpr(rightExpr)
 
+	// Array @> and <@ compare arrays element-wise and must not fall into the
+	// jsonb path, which would reject an array operand with a jsonb type error.
+	if (op == lexer.TokenAtArrow || op == lexer.TokenLtAt) && !leftJSONB && !rightJSONB {
+		if arrayType, ok := e.arrayContainmentType(leftExpr, rightExpr); ok {
+			value, err := e.arrayContainment(op, arrayType, leftExpr, rightExpr, left, right)
+			return value, true, err
+		}
+	}
+
 	if isComparisonOp(op) && (leftJSONB || rightJSONB) {
 		if left == nil || right == nil {
 			return nil, true, nil

+ 26 - 0
pkg/executor/jsonb_test.go

@@ -34,6 +34,8 @@ func jsonbErrorCode(t *testing.T, err error) string {
 		return pgerror.CodeFeatureNotSupported
 	case errors.Is(err, pgerror.ErrDatatypeMismatch):
 		return pgerror.CodeDatatypeMismatch
+	case errors.Is(err, pgerror.ErrInvalidColumnReference):
+		return pgerror.CodeInvalidColumnReference
 	}
 	var analysisErr *analyzer.AnalysisError
 	if errors.As(err, &analysisErr) && analysisErr.Type == analyzer.ErrTypeMismatch {
@@ -428,6 +430,28 @@ func TestJSONBSetReturningFunctionsInFrom(t *testing.T) {
 		t.Fatalf("aliased jsonb_each rows = %v", res.Rows)
 	}
 
+	// PostgreSQL's AS alias(col, ...) list renames the output columns.
+	res = execMust(t, e, `SELECT e.k, e.v FROM jsonb_each('{"a":1}') AS e(k, v)`)
+	if len(res.Columns) != 2 || res.Columns[0] != "k" || res.Columns[1] != "v" {
+		t.Fatalf("jsonb_each renamed columns = %v", res.Columns)
+	}
+	if res.RowCount != 1 || res.Rows[0][0] != "a" || res.Rows[0][1] != `1` || res.ColumnTypes[1] != "JSONB" {
+		t.Fatalf("jsonb_each renamed rows = %v types = %v", res.Rows, res.ColumnTypes)
+	}
+
+	res = execMust(t, e, `SELECT * FROM jsonb_each_text('{"a":true}') AS e(name, text_value)`)
+	if len(res.Columns) != 2 || res.Columns[0] != "name" || res.Columns[1] != "text_value" {
+		t.Fatalf("jsonb_each_text renamed columns = %v", res.Columns)
+	}
+	if res.Rows[0][1] != "true" {
+		t.Fatalf("jsonb_each_text renamed rows = %v", res.Rows)
+	}
+
+	res = execMust(t, e, `SELECT element FROM jsonb_array_elements('[1,2]') AS t(element) WHERE element @> '1'::jsonb`)
+	if res.RowCount != 1 || res.Rows[0][0] != `1` {
+		t.Fatalf("jsonb_array_elements renamed 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)
@@ -471,6 +495,8 @@ func TestJSONBSetReturningFunctionsInFrom(t *testing.T) {
 		{`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},
+		{`SELECT * FROM jsonb_each('{"a":1}') AS e(k)`, pgerror.CodeInvalidColumnReference},
+		{`SELECT * FROM jsonb_array_elements('[1]') AS t(a, b)`, pgerror.CodeInvalidColumnReference},
 	} {
 		if _, err := execSQL(e, tc.sql); jsonbErrorCode(t, err) != tc.code {
 			t.Errorf("%s error = %v, want %s", tc.sql, err, tc.code)

+ 7 - 0
pkg/httpserver/handler.go

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

+ 50 - 0
pkg/httpserver/sqlstate_test.go

@@ -1,6 +1,7 @@
 package httpserver
 
 import (
+	"bytes"
 	"encoding/json"
 	"errors"
 	"fmt"
@@ -201,3 +202,52 @@ func TestHTTPForeignKeyViolationReturns23503(t *testing.T) {
 		t.Fatalf("details.constraint = %q, want child_parent", constraint)
 	}
 }
+
+// Array containment and set-returning-function column aliases run through the
+// HTTP surface with the same values and SQLSTATEs as the wire protocol.
+func TestHTTPArrayContainmentAndSRFAlias(t *testing.T) {
+	server := setupKVTestServer(t)
+	queryHTTP(t, server, `CREATE TABLE items (id INTEGER PRIMARY KEY, nums INTEGER[])`)
+	queryHTTP(t, server, `INSERT INTO items (id, nums) VALUES (1, '{1,2,NULL}'), (2, '{3}')`)
+
+	resp := queryHTTP(t, server, `SELECT id FROM items WHERE nums @> ARRAY[2]`)
+	if len(resp.Rows) != 1 || resp.Rows[0][0].(float64) != 1 {
+		t.Fatalf("array containment rows = %#v", resp.Rows)
+	}
+	resp = queryHTTP(t, server, `SELECT id FROM items WHERE nums @> ARRAY[NULL]::INTEGER[]`)
+	if len(resp.Rows) != 1 || resp.Rows[0][0].(float64) != 1 {
+		t.Fatalf("NULL element containment rows = %#v", resp.Rows)
+	}
+
+	resp = queryHTTP(t, server, `SELECT n FROM unnest(ARRAY[5,6]) AS u(n) WHERE n = 6`)
+	if len(resp.Columns) != 1 || resp.Columns[0].Name != "n" || len(resp.Rows) != 1 || resp.Rows[0][0].(float64) != 6 {
+		t.Fatalf("alias list response = columns %#v rows %#v", resp.Columns, resp.Rows)
+	}
+
+	post := func(sql string) *httptest.ResponseRecorder {
+		body, _ := json.Marshal(QueryRequest{SQL: sql})
+		r := httptest.NewRequest(http.MethodPost, "/query", bytes.NewReader(body))
+		w := httptest.NewRecorder()
+		server.handleQuery(w, r)
+		return w
+	}
+	for _, tc := range []struct {
+		sql  string
+		code string
+	}{
+		{`SELECT * FROM unnest(ARRAY[1]) AS u(a, b)`, pgerror.CodeInvalidColumnReference},
+		{`SELECT * FROM items WHERE nums @> 1`, pgerror.CodeDatatypeMismatch},
+	} {
+		w := post(tc.sql)
+		if w.Code != http.StatusBadRequest {
+			t.Fatalf("%s status = %d, want 400; body %s", tc.sql, w.Code, w.Body.String())
+		}
+		var resp httpErrorBody
+		if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+			t.Fatalf("decode: %v", err)
+		}
+		if resp.Error.Code != tc.code {
+			t.Fatalf("%s code = %q, want %s", tc.sql, resp.Error.Code, tc.code)
+		}
+	}
+}

+ 22 - 0
pkg/parser/arrays_test.go

@@ -226,3 +226,25 @@ func TestBracketAliasShapesAfterExpression(t *testing.T) {
 		}
 	}
 }
+
+func TestTableFunctionColumnAliasListParses(t *testing.T) {
+	stmt := parseOne(t, `SELECT unnest FROM unnest(ARRAY[1,2,3]) AS t(unnest)`).(*SelectStmt)
+	if got := stmt.From[0].ColumnAliases; len(got) != 1 || got[0] != "unnest" {
+		t.Fatalf("unnest column aliases = %v, want [unnest]", got)
+	}
+	if stmt.From[0].Alias != "t" {
+		t.Fatalf("unnest table alias = %q, want t", stmt.From[0].Alias)
+	}
+
+	// The AS keyword is optional, and the list may rename several columns.
+	stmt = parseOne(t, `SELECT k, v FROM jsonb_each('{"a":1}') e(k, v)`).(*SelectStmt)
+	if got := stmt.From[0].ColumnAliases; len(got) != 2 || got[0] != "k" || got[1] != "v" {
+		t.Fatalf("jsonb_each column aliases = %v, want [k v]", got)
+	}
+
+	// An alias without a list keeps the function's default column names.
+	stmt = parseOne(t, `SELECT * FROM unnest(ARRAY[1]) AS u`).(*SelectStmt)
+	if len(stmt.From[0].ColumnAliases) != 0 {
+		t.Fatalf("alias-only column aliases = %v, want none", stmt.From[0].ColumnAliases)
+	}
+}

+ 29 - 3
pkg/parser/ast.go

@@ -1,6 +1,9 @@
 package parser
 
-import "github.com/danfragoso/pizzasql-next/pkg/lexer"
+import (
+	"github.com/danfragoso/pizzasql-next/pkg/lexer"
+	"github.com/danfragoso/pizzasql-next/pkg/pgerror"
+)
 
 // Node is the base interface for all AST nodes.
 type Node interface {
@@ -87,9 +90,32 @@ type TableRef struct {
 	Alias    string
 	Subquery *SelectStmt // for derived tables (SELECT ... FROM (SELECT ...) AS alias)
 	Join     *JoinClause // for joined tables
-	// Function is set for a table function in FROM (currently only unnest()).
-	// Name holds the function name so ordinary table dispatch ignores it.
+	// Function is set for a table function in FROM (currently only unnest() and
+	// the jsonb set-returning functions). Name holds the function name so
+	// ordinary table dispatch ignores it.
 	Function *FunctionCall
+	// ColumnAliases holds the optional `AS alias(col, ...)` list that renames a
+	// table function's output columns.
+	ColumnAliases []string
+}
+
+// ApplyColumnAliases renames a table function's output columns with the
+// reference's `AS alias(col, ...)` list. A list whose length does not match the
+// function's output arity is rejected, as PostgreSQL does.
+func (t *TableRef) ApplyColumnAliases(columns []string) ([]string, error) {
+	if len(t.ColumnAliases) == 0 {
+		return columns, nil
+	}
+	if len(t.ColumnAliases) != len(columns) {
+		name := t.Alias
+		if name == "" {
+			name = t.Name
+		}
+		return nil, pgerror.InvalidColumnReference(
+			"table %q has %d columns available but %d columns specified",
+			name, len(columns), len(t.ColumnAliases))
+	}
+	return append([]string(nil), t.ColumnAliases...), nil
 }
 
 // JoinClause represents a JOIN clause.

+ 33 - 0
pkg/parser/parser.go

@@ -643,6 +643,13 @@ func (p *Parser) parseTableRef(allowFunction bool) (*TableRef, error) {
 		if p.curTokenIs(lexer.TokenIdent) && !p.isClauseKeyword() {
 			ref.Alias = p.curToken.Literal
 			p.nextToken()
+			if p.curTokenIs(lexer.TokenLParen) {
+				aliases, err := p.parseColumnAliasList()
+				if err != nil {
+					return nil, err
+				}
+				ref.ColumnAliases = aliases
+			}
 		}
 		return ref, nil
 	}
@@ -674,6 +681,32 @@ func (p *Parser) parseTableRef(allowFunction bool) (*TableRef, error) {
 	return ref, nil
 }
 
+// parseColumnAliasList parses the parenthesized column alias list of a table
+// function: `AS t(a, b)`. The caller has already consumed the alias.
+func (p *Parser) parseColumnAliasList() ([]string, error) {
+	if !p.curTokenIs(lexer.TokenLParen) {
+		return nil, p.curError("expected ( before column aliases")
+	}
+	p.nextToken()
+	var aliases []string
+	for {
+		if !p.curTokenIs(lexer.TokenIdent) {
+			return nil, p.curError("expected a column alias")
+		}
+		aliases = append(aliases, p.curToken.Literal)
+		p.nextToken()
+		if !p.curTokenIs(lexer.TokenComma) {
+			break
+		}
+		p.nextToken()
+	}
+	if !p.curTokenIs(lexer.TokenRParen) {
+		return nil, p.curError("expected ) after column aliases")
+	}
+	p.nextToken()
+	return aliases, nil
+}
+
 func (p *Parser) isJoinKeyword() bool {
 	switch p.curToken.Type {
 	case lexer.TokenJOIN, lexer.TokenINNER, lexer.TokenLEFT,

+ 23 - 11
pkg/pgerror/pgerror.go

@@ -9,17 +9,18 @@ import (
 
 // SQLSTATE codes used by PizzaSQL's protocol and HTTP surfaces.
 const (
-	CodeFeatureNotSupported  = "0A000"
-	CodeInvalidText          = "22P02"
-	CodeInvalidParameter     = "22023"
-	CodeDatatypeMismatch     = "42804"
-	CodeUniqueViolation      = "23505"
-	CodeForeignKeyViolation  = "23503"
-	CodeCheckViolation       = "23514"
-	CodeSerializationFailure = "40001"
-	CodeSyntaxError          = "42601"
-	CodeUndefinedTable       = "42P01"
-	CodeInternalError        = "XX000"
+	CodeFeatureNotSupported    = "0A000"
+	CodeInvalidText            = "22P02"
+	CodeInvalidParameter       = "22023"
+	CodeDatatypeMismatch       = "42804"
+	CodeUniqueViolation        = "23505"
+	CodeForeignKeyViolation    = "23503"
+	CodeCheckViolation         = "23514"
+	CodeSerializationFailure   = "40001"
+	CodeSyntaxError            = "42601"
+	CodeUndefinedTable         = "42P01"
+	CodeInvalidColumnReference = "42P10"
+	CodeInternalError          = "XX000"
 )
 
 // ErrUndefinedTable marks a reference to a relation or type name that does not
@@ -67,6 +68,17 @@ func InvalidParameter(format string, args ...interface{}) error {
 	return fmt.Errorf("%w: %s", ErrInvalidParameter, fmt.Sprintf(format, args...))
 }
 
+// ErrInvalidColumnReference marks a reference whose shape does not match the
+// relation it names, such as a table-function column alias list with the wrong
+// arity. Protocol layers map it to SQLSTATE 42P10 (invalid_column_reference).
+var ErrInvalidColumnReference = errors.New("invalid column reference")
+
+// InvalidColumnReference returns an error that wraps
+// ErrInvalidColumnReference with a client-facing explanation.
+func InvalidColumnReference(format string, args ...interface{}) error {
+	return fmt.Errorf("%w: %s", ErrInvalidColumnReference, 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).

+ 89 - 0
pkg/pgserver/array_containment_test.go

@@ -0,0 +1,89 @@
+package pgserver
+
+import "testing"
+
+// Array @> and <@ compare one-dimensional arrays element-wise over the wire
+// instead of falling into the jsonb path.
+func TestArrayContainmentOverTheWire(t *testing.T) {
+	c, conn := arrayConnection(t)
+
+	conn.reset()
+	messages := runSimple(t, c, conn, `SELECT ARRAY[1,2,3] @> ARRAY[1], ARRAY[1] <@ ARRAY[1,2], ARRAY[1] @> ARRAY[2], NULL::INTEGER[] @> ARRAY[1], ARRAY[1] @> ARRAY[]::INTEGER[]`)
+	if errMsg := findError(messages); errMsg != nil {
+		t.Fatalf("array containment failed: %s %s", errorCode(errMsg), string(errMsg.Data))
+	}
+	row := firstDataRow(t, messages)
+	want := []string{"true", "true", "false", "<null>", "true"}
+	if len(row) != len(want) {
+		t.Fatalf("array containment row = %v, want %v", row, want)
+	}
+	for i := range want {
+		if row[i] != want[i] {
+			t.Fatalf("array containment row = %v, want %v", row, want)
+		}
+	}
+
+	conn.reset()
+	if errMsg := findError(runSimple(t, c, conn, `CREATE TABLE items (id INTEGER PRIMARY KEY, nums INTEGER[])`)); errMsg != nil {
+		t.Fatalf("create items: %s", errorCode(errMsg))
+	}
+	conn.reset()
+	if errMsg := findError(runSimple(t, c, conn, `INSERT INTO items VALUES (1, '{1,2,3}'), (2, '{4}'), (3, NULL)`)); errMsg != nil {
+		t.Fatalf("insert items: %s", errorCode(errMsg))
+	}
+	conn.reset()
+	messages = runSimple(t, c, conn, `SELECT id FROM items WHERE nums @> ARRAY[2] AND nums <@ ARRAY[1,2,3]`)
+	if errMsg := findError(messages); errMsg != nil {
+		t.Fatalf("filtered containment: %s", errorCode(errMsg))
+	}
+	if row := firstDataRow(t, messages); len(row) != 1 || row[0] != "1" {
+		t.Fatalf("filtered containment row = %v, want [1]", row)
+	}
+
+	// A scalar operand is still a type mismatch, not an empty result.
+	conn.reset()
+	messages = runSimple(t, c, conn, `SELECT ARRAY[1] @> 1`)
+	if errMsg := findError(messages); errMsg == nil || errorCode(errMsg) != ErrCodeDatatypeMismatch {
+		t.Fatalf("scalar containment error = %v, want 42804", messages)
+	}
+}
+
+// A parameter on the right of an array containment operator is inferred as the
+// column's array type, so the extended protocol binds an array rather than text.
+func TestArrayContainmentParameterBinding(t *testing.T) {
+	c, conn := arrayConnection(t)
+	cases := []struct {
+		query string
+		oid   int32
+		value string
+	}{
+		{`SELECT id FROM items WHERE nums @> $1`, 1007, `{2}`},
+		{`SELECT id FROM items WHERE $1 <@ nums`, 1007, `{1,2}`},
+	}
+	for _, tc := range cases {
+		conn.reset()
+		if errMsg := findError(runSimple(t, c, conn, `CREATE TABLE items (id INTEGER PRIMARY KEY, nums INTEGER[])`)); errMsg != nil {
+			if errorCode(errMsg) != "42P07" {
+				t.Fatalf("create items: %s", errorCode(errMsg))
+			}
+		}
+		conn.reset()
+		if errMsg := findError(runSimple(t, c, conn, `INSERT INTO items VALUES (1, '{1,2,3}'), (2, '{4}')`)); errMsg != nil {
+			t.Fatalf("insert items: %s", errorCode(errMsg))
+		}
+
+		conn.reset()
+		messages := runExtendedParseBindExecute(t, c, conn, tc.query, tc.oid, tc.value)
+		if errMsg := findError(messages); errMsg != nil {
+			t.Fatalf("%s: %s", tc.query, errorCode(errMsg))
+		}
+		if row := firstDataRow(t, messages); len(row) != 1 || row[0] != "1" {
+			t.Fatalf("%s row = %v, want [1]", tc.query, row)
+		}
+
+		conn.reset()
+		if errMsg := findError(runSimple(t, c, conn, `DROP TABLE items`)); errMsg != nil {
+			t.Fatalf("drop items: %s", errorCode(errMsg))
+		}
+	}
+}

+ 9 - 0
pkg/pgserver/compat_suite_test.go

@@ -150,6 +150,15 @@ func TestCompatibilitySuite(t *testing.T) {
 	expectRows("arrays.length", `SELECT array_length(tags, 1) FROM array_values`, "2")
 	expectRows("arrays.any_parameter", `SELECT count(*) FROM array_values WHERE id = ANY('{1}')`, "1")
 	expectCode("arrays.multidim_0A000", `CREATE TABLE array_bad (v INTEGER[][])`, "0A000")
+	expectRows("arrays.contains", `SELECT count(*) FROM array_values WHERE nums @> ARRAY[1]`, "1")
+	expectRows("arrays.contains_missing", `SELECT count(*) FROM array_values WHERE nums @> ARRAY[9]`, "0")
+	expectRows("arrays.contained_by", `SELECT count(*) FROM array_values WHERE ARRAY[1] <@ nums`, "1")
+	expectRows("arrays.contains_null_element", `SELECT count(*) FROM array_values WHERE nums @> ARRAY[NULL]::INTEGER[]`, "1")
+	expectRows("arrays.contains_empty", `SELECT count(*) FROM array_values WHERE nums @> ARRAY[]::INTEGER[]`, "1")
+	expectRows("arrays.contains_null_operand", `SELECT count(*) FROM array_values WHERE nums @> NULL`, "0")
+	expectRows("srf.unnest_alias_list", `SELECT count(*) FROM unnest(ARRAY[1,2]) AS t(n) WHERE n > 1`, "1")
+	expectRows("srf.jsonb_each_alias_list", `SELECT count(*) FROM jsonb_each('{"a":1}') AS e(k, v) WHERE k = 'a'`, "1")
+	expectCode("srf.alias_list_arity_42P10", `SELECT * FROM jsonb_each('{"a":1}') AS e(k)`, "42P10")
 
 	// CHECK and FOREIGN KEY are accepted and enforced.
 	expectOK("ddl.check_accepted", `CREATE TABLE checked (n INTEGER CHECK (n > 0))`)

+ 10 - 0
pkg/pgserver/connection.go

@@ -466,6 +466,11 @@ func (c *Connection) handleQuery(msg *Message) error {
 				if c.txStatus == TxStatusInBlock {
 					c.txStatus = TxStatusFailed
 				}
+			} else if errors.Is(err, pgerror.ErrInvalidColumnReference) {
+				code = ErrCodeInvalidColumnReference
+				if c.txStatus == TxStatusInBlock {
+					c.txStatus = TxStatusFailed
+				}
 			} else if errors.Is(err, pgerror.ErrUndefinedTable) || analysisUndefinedTable(err) {
 				code = ErrCodeUndefinedTable
 				if c.txStatus == TxStatusInBlock {
@@ -808,6 +813,11 @@ func (c *Connection) classifyExecuteError(stmt parser.Statement, err error) *exe
 		if c.txStatus == TxStatusInBlock {
 			c.txStatus = TxStatusFailed
 		}
+	} else if errors.Is(err, pgerror.ErrInvalidColumnReference) {
+		code = ErrCodeInvalidColumnReference
+		if c.txStatus == TxStatusInBlock {
+			c.txStatus = TxStatusFailed
+		}
 	} else if errors.Is(err, pgerror.ErrUndefinedTable) || analysisUndefinedTable(err) {
 		code = ErrCodeUndefinedTable
 		if c.txStatus == TxStatusInBlock {

+ 2 - 0
pkg/pgserver/param_types_test.go

@@ -69,6 +69,8 @@ func TestNegotiatedParameterOIDs(t *testing.T) {
 		{"placeholder_in_comment", `SELECT id FROM t WHERE id = 1 -- $1`, nil, []int32{}},
 		{"first_occurrence_wins", `SELECT id FROM t WHERE id = $1 OR big = $1`, nil, []int32{23}},
 		{"array_column_any", `SELECT tags FROM arr WHERE tags = ANY($1)`, nil, []int32{1009}},
+		{"array_containment", `SELECT id FROM arr WHERE nums @> $1`, nil, []int32{1007}},
+		{"array_containment_reversed", `SELECT id FROM arr WHERE $1 <@ nums`, nil, []int32{1007}},
 		{"limit_bigint", `SELECT id FROM t LIMIT $1 OFFSET $2`, nil, []int32{20, 20}},
 		{"join_condition", `SELECT t.id FROM t JOIN arr ON t.big = $1`, nil, []int32{20}},
 		{"join_subquery_body", `SELECT t.id FROM t JOIN (SELECT id FROM arr WHERE nums[1] = $1) AS x ON x.id = t.id`, nil, []int32{23}},

+ 22 - 21
pkg/pgserver/protocol.go

@@ -83,27 +83,28 @@ const (
 
 // PostgreSQL error codes (subset)
 const (
-	ErrCodeSuccess              = "00000"
-	ErrCodeSyntaxError          = "42601"
-	ErrCodeProgramLimitExceeded = "54000"
-	ErrCodeUndefinedTable       = "42P01"
-	ErrCodeUndefinedColumn      = "42703"
-	ErrCodeDuplicateTable       = "42P07"
-	ErrCodeDuplicateColumn      = "42701"
-	ErrCodeDuplicateCursor      = "42P03"
-	ErrCodeInvalidParameter     = "22023"
-	ErrCodeInvalidText          = "22P02"
-	ErrCodeDatatypeMismatch     = "42804"
-	ErrCodeInternalError        = "XX000"
-	ErrCodeConnectionFailure    = "08006"
-	ErrCodeProtocolViolation    = "08P01"
-	ErrCodeFeatureNotSupported  = "0A000"
-	ErrCodeUndefinedCursor      = "34000"
-	ErrCodeTransactionAborted   = "25P02"
-	ErrCodeSerializationFailure = "40001"
-	ErrCodeUniqueViolation      = "23505"
-	ErrCodeForeignKeyViolation  = "23503"
-	ErrCodeCheckViolation       = "23514"
+	ErrCodeSuccess                = "00000"
+	ErrCodeSyntaxError            = "42601"
+	ErrCodeProgramLimitExceeded   = "54000"
+	ErrCodeUndefinedTable         = "42P01"
+	ErrCodeInvalidColumnReference = "42P10"
+	ErrCodeUndefinedColumn        = "42703"
+	ErrCodeDuplicateTable         = "42P07"
+	ErrCodeDuplicateColumn        = "42701"
+	ErrCodeDuplicateCursor        = "42P03"
+	ErrCodeInvalidParameter       = "22023"
+	ErrCodeInvalidText            = "22P02"
+	ErrCodeDatatypeMismatch       = "42804"
+	ErrCodeInternalError          = "XX000"
+	ErrCodeConnectionFailure      = "08006"
+	ErrCodeProtocolViolation      = "08P01"
+	ErrCodeFeatureNotSupported    = "0A000"
+	ErrCodeUndefinedCursor        = "34000"
+	ErrCodeTransactionAborted     = "25P02"
+	ErrCodeSerializationFailure   = "40001"
+	ErrCodeUniqueViolation        = "23505"
+	ErrCodeForeignKeyViolation    = "23503"
+	ErrCodeCheckViolation         = "23514"
 )
 
 // Message represents a PostgreSQL protocol message

+ 77 - 0
pkg/pgserver/srf_alias_test.go

@@ -0,0 +1,77 @@
+package pgserver
+
+import "testing"
+
+// PostgreSQL's `AS alias(col, ...)` list renames a set-returning function's
+// output columns in the projection and in WHERE.
+func TestSetReturningFunctionColumnAliases(t *testing.T) {
+	c, conn := arrayConnection(t)
+
+	conn.reset()
+	messages := runSimple(t, c, conn, `SELECT unnest FROM unnest(ARRAY[1,2,3]) AS t(unnest)`)
+	if errMsg := findError(messages); errMsg != nil {
+		t.Fatalf("unnest alias list failed: %s", errorCode(errMsg))
+	}
+	columns := rowDescriptionColumns(t, findRowDescription(t, messages).Data)
+	if len(columns) != 1 || columns[0] != "unnest" {
+		t.Fatalf("unnest alias columns = %v, want [unnest]", columns)
+	}
+	if rows := countMessageType(messages, MsgDataRow); rows != 3 {
+		t.Fatalf("unnest alias rows = %d, want 3", rows)
+	}
+
+	conn.reset()
+	messages = runSimple(t, c, conn, `SELECT n FROM unnest(ARRAY[10,20,30]) AS t(n) WHERE n > 10 ORDER BY n`)
+	if errMsg := findError(messages); errMsg != nil {
+		t.Fatalf("unnest renamed column failed: %s", errorCode(errMsg))
+	}
+	if columns := rowDescriptionColumns(t, findRowDescription(t, messages).Data); len(columns) != 1 || columns[0] != "n" {
+		t.Fatalf("unnest renamed columns = %v, want [n]", columns)
+	}
+	if row := firstDataRow(t, messages); len(row) != 1 || row[0] != "20" {
+		t.Fatalf("unnest renamed first row = %v, want [20]", row)
+	}
+
+	conn.reset()
+	messages = runSimple(t, c, conn, `SELECT k, v FROM jsonb_each('{"b":2,"a":1}') AS e(k, v) ORDER BY k`)
+	if errMsg := findError(messages); errMsg != nil {
+		t.Fatalf("jsonb_each renamed columns failed: %s", errorCode(errMsg))
+	}
+	if columns := rowDescriptionColumns(t, findRowDescription(t, messages).Data); len(columns) != 2 || columns[0] != "k" || columns[1] != "v" {
+		t.Fatalf("jsonb_each renamed columns = %v, want [k v]", columns)
+	}
+	if row := firstDataRow(t, messages); len(row) != 2 || row[0] != "a" || row[1] != "1" {
+		t.Fatalf("jsonb_each renamed first row = %v, want [a 1]", row)
+	}
+
+	// An alias without a column list keeps the default names.
+	conn.reset()
+	messages = runSimple(t, c, conn, `SELECT key, value FROM jsonb_each('{"a":1}') AS e`)
+	if errMsg := findError(messages); errMsg != nil {
+		t.Fatalf("alias-only jsonb_each failed: %s", errorCode(errMsg))
+	}
+	if columns := rowDescriptionColumns(t, findRowDescription(t, messages).Data); len(columns) != 2 || columns[0] != "key" || columns[1] != "value" {
+		t.Fatalf("alias-only columns = %v, want [key value]", columns)
+	}
+}
+
+// An alias list with the wrong arity reports invalid_column_reference (42P10).
+func TestSetReturningFunctionColumnAliasArity(t *testing.T) {
+	c, conn := arrayConnection(t)
+	for _, sql := range []string{
+		`SELECT * FROM unnest(ARRAY[1]) AS t(a, b)`,
+		`SELECT * FROM jsonb_each('{"a":1}') AS e(k)`,
+	} {
+		conn.reset()
+		messages := runSimple(t, c, conn, sql)
+		errMsg := findError(messages)
+		if errMsg == nil || errorCode(errMsg) != ErrCodeInvalidColumnReference {
+			t.Fatalf("%s error = %v, want 42P10", sql, messages)
+		}
+	}
+	conn.reset()
+	messages := runExtendedParseBindExecute(t, c, conn, `SELECT * FROM unnest($1) AS t(a, b)`, 1007, `{1}`)
+	if errMsg := findError(messages); errMsg == nil || errorCode(errMsg) != ErrCodeInvalidColumnReference {
+		t.Fatalf("extended alias arity error = %v, want 42P10", messages)
+	}
+}