Просмотр исходного кода

Merge pull request #38 from database-pizza/engine/c-arrays

add array types, ANY/ALL predicates, and array parameters
Danilo Fragoso 2 дней назад
Родитель
Сommit
935caecf06

+ 122 - 4
pkg/analyzer/analyzer.go

@@ -294,6 +294,27 @@ func (a *Analyzer) resolveFromClause(tables []parser.TableRef) error {
 // defineTableRef adds a single FROM/JOIN table to scope. The reference may be a
 // derived table (subquery), a CTE, or a real catalog table.
 func (a *Analyzer) defineTableRef(ref parser.TableRef) error {
+	if ref.Function != nil {
+		for _, arg := range ref.Function.Args {
+			if _, err := a.analyzeExpr(arg); err != nil {
+				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,
+			}},
+		})
+		return nil
+	}
+
 	if ref.Subquery != nil {
 		if err := a.analyzeSelect(ref.Subquery); err != nil {
 			return err
@@ -342,11 +363,11 @@ func (a *Analyzer) derivedTableInfo(ref parser.TableRef) *TableInfo {
 			for _, tref := range collectAllTableRefs(leg.From) {
 				if t, ok := a.scope.LookupTable(tref.Name); ok {
 					for _, c := range t.Columns {
-						info.Columns = append(info.Columns, ColumnInfo{Name: c.Name, TableName: ref.Alias, Type: c.Type})
+						info.Columns = append(info.Columns, ColumnInfo{Name: c.Name, TableName: ref.Alias, Type: c.Type, Array: c.Array})
 					}
 				} else if t, ok := a.catalog.GetTable(tref.Name); ok {
 					for _, c := range t.Columns {
-						info.Columns = append(info.Columns, ColumnInfo{Name: c.Name, TableName: ref.Alias, Type: c.Type})
+						info.Columns = append(info.Columns, ColumnInfo{Name: c.Name, TableName: ref.Alias, Type: c.Type, Array: c.Array})
 					}
 				}
 			}
@@ -726,6 +747,7 @@ func (a *Analyzer) analyzeCreateTable(stmt *parser.CreateTableStmt) error {
 			Nullable:  true,
 			TableName: stmt.Table.Name,
 			Generated: colDef.GeneratedExpr != nil,
+			Array:     colDef.Type.Array,
 		}
 
 		// Process constraints
@@ -844,6 +866,12 @@ func (a *Analyzer) analyzeExpr(expr parser.Expr) (*ExprInfo, error) {
 		}, nil
 	case *parser.ExistsExpr:
 		return a.analyzeExistsExpr(e)
+	case *parser.ArrayExpr:
+		return a.analyzeArrayExpr(e)
+	case *parser.SubscriptExpr:
+		return a.analyzeSubscriptExpr(e)
+	case *parser.AnyAllExpr:
+		return a.analyzeAnyAllExpr(e)
 	case *parser.SubqueryExpr:
 		return a.analyzeSubqueryExpr(e)
 	case *parser.WindowExpr:
@@ -853,6 +881,67 @@ func (a *Analyzer) analyzeExpr(expr parser.Expr) (*ExprInfo, error) {
 	}
 }
 
+// analyzeArrayExpr validates every element of an ARRAY[...] constructor.
+func (a *Analyzer) analyzeArrayExpr(e *parser.ArrayExpr) (*ExprInfo, error) {
+	info := &ExprInfo{Type: TypeUnknown, Array: true}
+	for _, element := range e.Elements {
+		elementInfo, err := a.analyzeExpr(element)
+		if err != nil {
+			return nil, err
+		}
+		if elementInfo.IsAggregate {
+			info.IsAggregate = true
+		}
+		if elementInfo.Nullable || element == nil {
+			info.Nullable = true
+		}
+		if elementInfo.Type != TypeNull {
+			info.Type = elementInfo.Type
+		}
+	}
+	return info, nil
+}
+
+// analyzeSubscriptExpr validates the array expression and the index of arr[idx].
+func (a *Analyzer) analyzeSubscriptExpr(e *parser.SubscriptExpr) (*ExprInfo, error) {
+	arrayInfo, err := a.analyzeExpr(e.Expr)
+	if err != nil {
+		return nil, err
+	}
+	indexInfo, err := a.analyzeExpr(e.Index)
+	if err != nil {
+		return nil, err
+	}
+	if indexInfo.Type != TypeUnknown && indexInfo.Type != TypeNull && !indexInfo.Type.IsNumeric() {
+		return nil, &AnalysisError{
+			Type:    ErrTypeMismatch,
+			Message: "array subscript must be an integer expression",
+		}
+	}
+	return &ExprInfo{
+		Type:        arrayInfo.Type,
+		IsAggregate: arrayInfo.IsAggregate || indexInfo.IsAggregate,
+		Nullable:    true,
+	}, nil
+}
+
+// analyzeAnyAllExpr validates both sides of a quantified comparison.
+func (a *Analyzer) analyzeAnyAllExpr(e *parser.AnyAllExpr) (*ExprInfo, error) {
+	leftInfo, err := a.analyzeExpr(e.Left)
+	if err != nil {
+		return nil, err
+	}
+	arrayInfo, err := a.analyzeExpr(e.Array)
+	if err != nil {
+		return nil, err
+	}
+	return &ExprInfo{
+		Type:        TypeBoolean,
+		IsAggregate: leftInfo.IsAggregate || arrayInfo.IsAggregate,
+		Nullable:    true,
+	}, nil
+}
+
 // analyzeWindowExpr analyzes a window function. Window functions are not
 // aggregates for the purpose of GROUP BY handling.
 func (a *Analyzer) analyzeWindowExpr(e *parser.WindowExpr) (*ExprInfo, error) {
@@ -940,6 +1029,7 @@ func (a *Analyzer) analyzeColumnRef(e *parser.ColumnRef) (*ExprInfo, error) {
 	return &ExprInfo{
 		Type:     col.Type,
 		Nullable: col.Nullable,
+		Array:    col.Array,
 	}, nil
 }
 
@@ -989,9 +1079,12 @@ func (a *Analyzer) analyzeBinaryExpr(e *parser.BinaryExpr) (*ExprInfo, error) {
 			}
 		}
 	case lexer.TokenEq, lexer.TokenNeq, lexer.TokenLt, lexer.TokenLte, lexer.TokenGt, lexer.TokenGte:
-		// Comparison operators
+		// Comparison operators. A text literal is accepted against an array
+		// column: the runtime coerces and canonicalizes the literal to the
+		// column's array type, so `nums = '{3,4}'` compares arrays rather than
+		// INTEGER with TEXT.
 		info.Type = TypeBoolean
-		if !left.Type.IsComparable(right.Type) {
+		if !comparisonTypesCompatible(left, right) {
 			return nil, &AnalysisError{
 				Type:    ErrTypeMismatch,
 				Message: fmt.Sprintf("cannot compare %s with %s", left.Type, right.Type),
@@ -1025,6 +1118,30 @@ func (a *Analyzer) analyzeBinaryExpr(e *parser.BinaryExpr) (*ExprInfo, error) {
 	return info, nil
 }
 
+// comparisonTypesCompatible reports whether two comparison operands can be
+// compared. Arrays only compare with arrays or with text that can be read as
+// array text, so `nums = '{3,4}'` is accepted while `nums = 5` is not.
+func comparisonTypesCompatible(left, right *ExprInfo) bool {
+	if left.Array || right.Array {
+		return (left.Array && right.Array) || arrayLiteralComparison(left, right)
+	}
+	return left.Type.IsComparable(right.Type)
+}
+
+// arrayLiteralComparison reports whether one side is an array expression and the
+// other is a text expression that can be read as array text.
+func arrayLiteralComparison(left, right *ExprInfo) bool {
+	textCoercible := func(info *ExprInfo) bool {
+		switch info.Type {
+		case TypeText, TypeUnknown, TypeNull, TypeAny:
+			return true
+		default:
+			return false
+		}
+	}
+	return (left.Array && textCoercible(right)) || (right.Array && textCoercible(left))
+}
+
 func (a *Analyzer) analyzeUnaryExpr(e *parser.UnaryExpr) (*ExprInfo, error) {
 	operand, err := a.analyzeExpr(e.Operand)
 	if err != nil {
@@ -1200,6 +1317,7 @@ func (a *Analyzer) analyzeCastExpr(e *parser.CastExpr) (*ExprInfo, error) {
 		IsAggregate: exprInfo.IsAggregate,
 		IsConstant:  exprInfo.IsConstant,
 		Nullable:    exprInfo.Nullable,
+		Array:       e.Type.Array,
 	}, nil
 }
 

+ 104 - 0
pkg/analyzer/arrays_test.go

@@ -0,0 +1,104 @@
+package analyzer
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/danfragoso/pizzasql-next/pkg/parser"
+)
+
+// arrayCatalog describes an array-typed table so expression analysis sees the
+// same declared types the executor resolves from storage.
+func arrayCatalog() *Catalog {
+	catalog := NewCatalog()
+	catalog.CreateTable(&TableInfo{
+		Name: "documents",
+		Columns: []ColumnInfo{
+			{Name: "id", Type: TypeInteger, PrimaryKey: true},
+			{Name: "tags", Type: TypeFromName("TEXT[]"), Nullable: true, Array: true},
+			{Name: "nums", Type: TypeFromName("INTEGER[]"), Nullable: true, Array: true},
+		},
+	})
+	return catalog
+}
+
+func TestArrayTypeNameKeepsElementAffinity(t *testing.T) {
+	cases := map[string]Type{
+		"TEXT[]":             TypeText,
+		"VARCHAR(10)[]":      TypeText,
+		"INTEGER[]":          TypeInteger,
+		"BIGINT[]":           TypeInteger,
+		"BOOLEAN[]":          TypeBoolean,
+		"DOUBLE PRECISION[]": TypeReal,
+		"NUMERIC[]":          TypeNumeric,
+	}
+	for name, want := range cases {
+		if got := TypeFromName(name); got != want {
+			t.Errorf("TypeFromName(%q) = %v, want %v", name, got, want)
+		}
+	}
+}
+
+func TestArrayColumnDDLAnalyzes(t *testing.T) {
+	a := New(setupCatalog())
+	if err := a.Analyze(parse(t, `CREATE TABLE tagged (id INTEGER PRIMARY KEY, tags TEXT[], nums INTEGER[])`)); err != nil {
+		t.Fatalf("array column DDL: %v", err)
+	}
+	if err := a.Analyze(parse(t, `ALTER TABLE tagged ADD COLUMN flags BOOLEAN[]`)); err != nil {
+		t.Fatalf("ALTER TABLE ADD array column: %v", err)
+	}
+}
+
+func TestArrayExpressionsAnalyze(t *testing.T) {
+	a := New(arrayCatalog())
+	for _, sql := range []string{
+		`SELECT tags FROM documents WHERE 'go' = ANY(tags)`,
+		`SELECT tags FROM documents WHERE 'go' <> ALL(tags)`,
+		`SELECT nums FROM documents WHERE id = ANY(ARRAY[1,2])`,
+		`SELECT tags[1] FROM documents WHERE tags[1] = 'go'`,
+		`SELECT array_length(tags, 1) FROM documents`,
+		`SELECT ARRAY['a','b'] FROM documents`,
+		`SELECT '{1,2}'::INTEGER[] FROM documents`,
+		`SELECT nums FROM documents WHERE nums = '{3,4}'`,
+		`SELECT nums FROM documents WHERE '{3,4}' = nums`,
+		`SELECT nums FROM documents WHERE nums <> '{3,4}'`,
+	} {
+		if err := a.Analyze(parse(t, sql)); err != nil {
+			t.Errorf("analyze %q: %v", sql, err)
+		}
+	}
+}
+
+func TestArrayExpressionRejections(t *testing.T) {
+	a := New(arrayCatalog())
+	cases := []struct {
+		sql  string
+		want string
+	}{
+		{`SELECT missing[1] FROM documents`, "column not found"},
+		{`SELECT tags['x'] FROM documents`, "array subscript must be an integer expression"},
+		// A text literal is only coercible to an array side, not to a scalar
+		// integer column.
+		{`SELECT id FROM documents WHERE id = '{3,4}'`, "cannot compare INTEGER with TEXT"},
+		// An array column still does not compare with a scalar number.
+		{`SELECT nums FROM documents WHERE nums = 5`, "cannot compare INTEGER with INTEGER"},
+	}
+	for _, tc := range cases {
+		err := a.Analyze(parse(t, tc.sql))
+		if err == nil {
+			t.Errorf("%q analyzed, want an error", tc.sql)
+			continue
+		}
+		if !strings.Contains(err.Error(), tc.want) {
+			t.Errorf("%q error = %v, want it to mention %q", tc.sql, err, tc.want)
+		}
+	}
+}
+
+func TestUnnestFromDefinesRelation(t *testing.T) {
+	a := New(arrayCatalog())
+	stmt := parse(t, `SELECT u.unnest FROM unnest('{1,2}'::INTEGER[]) AS u`).(*parser.SelectStmt)
+	if err := a.Analyze(stmt); err != nil {
+		t.Fatalf("unnest FROM analysis: %v", err)
+	}
+}

+ 13 - 1
pkg/analyzer/types.go

@@ -48,7 +48,10 @@ func (t Type) String() string {
 // TypeFromName returns the Type for a SQL type name using SQLite affinity rules.
 // See: https://www.sqlite.org/datatype3.html
 func TypeFromName(name string) Type {
-	upper := strings.ToUpper(name)
+	upper := strings.ToUpper(strings.TrimSpace(name))
+	// Array types report their element type's affinity. The analyzer has no
+	// distinct array type; element storage stays a canonical text value.
+	upper = strings.TrimSuffix(upper, "[]")
 
 	// Rule 1: If the type contains "INT" -> INTEGER
 	if strings.Contains(upper, "INT") {
@@ -224,6 +227,10 @@ var builtinFunctions = map[string]FunctionSignature{
 	"IFNULL":   {Name: "IFNULL", MinArgs: 2, MaxArgs: 2, ArgTypes: []Type{TypeAny, TypeAny}, ReturnType: TypeAny, IsAggregate: false},
 	"IIF":      {Name: "IIF", MinArgs: 3, MaxArgs: 3, ArgTypes: []Type{TypeBoolean, TypeAny, TypeAny}, ReturnType: TypeAny, IsAggregate: false},
 
+	// Array functions
+	"ARRAY_LENGTH": {Name: "ARRAY_LENGTH", MinArgs: 2, MaxArgs: 2, ArgTypes: []Type{TypeAny, TypeInteger}, ReturnType: TypeInteger, IsAggregate: false},
+	"UNNEST":       {Name: "UNNEST", MinArgs: 1, MaxArgs: 1, ArgTypes: []Type{TypeAny}, ReturnType: TypeAny, IsAggregate: false},
+
 	// Type functions
 	"TYPEOF": {Name: "TYPEOF", MinArgs: 1, MaxArgs: 1, ArgTypes: []Type{TypeAny}, ReturnType: TypeText, IsAggregate: false},
 	"CAST":   {Name: "CAST", MinArgs: 1, MaxArgs: 1, ArgTypes: []Type{TypeAny}, ReturnType: TypeAny, IsAggregate: false},
@@ -316,6 +323,9 @@ type ColumnInfo struct {
 	Default    interface{}
 	TableName  string // For qualified references
 	Generated  bool   // generated columns cannot be written by the user
+	// Array marks a one-dimensional array column. Type holds the element's
+	// affinity, so comparisons need this flag to accept a text array literal.
+	Array bool
 }
 
 // TableInfo describes a table schema.
@@ -347,4 +357,6 @@ type ExprInfo struct {
 	IsAggregate bool
 	IsConstant  bool
 	Nullable    bool
+	// Array marks an expression whose value is an array.
+	Array bool
 }

+ 3 - 3
pkg/capabilities/capabilities.go

@@ -205,14 +205,14 @@ func Current() *Document {
 			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"},
-			Arrays:   Feature{Supported: false, Status: "rejected", SQLState: "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; arrays are rejected",
-			Arrays:        Feature{Supported: false, Status: "rejected", SQLState: "0A000", Notes: "id = ANY($1) is rejected; expand to IN (...) with individual parameters"},
+			MaxOID:        "int8/text/bool/bytea/float plus 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,
 		},
 

+ 32 - 0
pkg/capabilities/capabilities_test.go

@@ -189,3 +189,35 @@ func TestForeignKeyIsAdvertised(t *testing.T) {
 		t.Fatalf("23503 mapping = %q, want foreign_key_violation", got)
 	}
 }
+
+func TestArraysAreAdvertised(t *testing.T) {
+	doc := Current()
+	types := doc.Types.Arrays
+	if !types.Supported {
+		t.Fatalf("types.arrays = %+v, want supported", types)
+	}
+	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"} {
+		if !strings.Contains(types.Notes, want) {
+			t.Errorf("types.arrays notes do not mention %q", want)
+		}
+	}
+
+	params := doc.Parameters.Arrays
+	if !params.Supported {
+		t.Fatalf("parameters.arrays = %+v, want supported", params)
+	}
+	if params.Status != "text_format" {
+		t.Fatalf("parameters.arrays status = %q, want text_format", params.Status)
+	}
+	for _, want := range []string{"1009", "1007", "1016", "1000", "1022", "1021", "1231", "1015", "0A000"} {
+		if !strings.Contains(params.Notes, want) {
+			t.Errorf("parameters.arrays notes do not mention %q", want)
+		}
+	}
+	if !strings.Contains(doc.Parameters.MaxOID, "text[]/varchar[]/int4[]") {
+		t.Fatalf("parameters.max_oid = %q, want the array OIDs listed", doc.Parameters.MaxOID)
+	}
+}

+ 973 - 0
pkg/executor/arrays.go

@@ -0,0 +1,973 @@
+package executor
+
+import (
+	"fmt"
+	"math/big"
+	"strconv"
+	"strings"
+
+	"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"
+)
+
+// Array values are carried as their canonical PostgreSQL array-literal text.
+// Storing the text keeps the row codec unchanged (a string is a string), makes
+// NULL and the empty array distinguishable (nil vs "{}"), and gives reloads a
+// stable encoding: every write path canonicalizes before storage, so equal
+// arrays always encode to identical bytes.
+//
+// The canonical form uses unquoted elements when the element text is unambiguous
+// and double-quoted (backslash-escaped) elements otherwise. Element text is
+// canonicalized per declared element type: integers to decimal, reals to Go's
+// shortest round-trip form, booleans to t/f.
+
+// arrayElementKind classifies an array element type for canonicalization.
+type arrayElementKind int
+
+const (
+	arrayElementText arrayElementKind = iota
+	arrayElementInteger
+	arrayElementReal
+	arrayElementNumeric
+	arrayElementBoolean
+)
+
+// arrayElementType reports the base element type of an array type name such as
+// "INTEGER[]". It reports false for non-array type names.
+func arrayElementType(typeName string) (string, bool) {
+	upper := strings.ToUpper(strings.TrimSpace(typeName))
+	if !strings.HasSuffix(upper, "[]") {
+		return "", false
+	}
+	return strings.TrimSpace(strings.TrimSuffix(upper, "[]")), true
+}
+
+// arrayElementKindOf reports the canonicalization kind for an array type name.
+func arrayElementKindOf(typeName string) (arrayElementKind, error) {
+	element, ok := arrayElementType(typeName)
+	if !ok {
+		return arrayElementText, fmt.Errorf("%s is not an array type", typeName)
+	}
+	return arrayElementKindForName(element)
+}
+
+// arrayElementKindForName classifies a scalar element type name.
+func arrayElementKindForName(name string) (arrayElementKind, error) {
+	switch strings.ToUpper(strings.TrimSpace(name)) {
+	case "TEXT", "VARCHAR", "CHAR", "CHARACTER", "CLOB", "NCHAR", "NVARCHAR", "UUID":
+		return arrayElementText, nil
+	case "INTEGER", "INT", "TINYINT", "SMALLINT", "MEDIUMINT", "BIGINT":
+		return arrayElementInteger, nil
+	case "REAL", "FLOAT", "DOUBLE":
+		return arrayElementReal, nil
+	case "NUMERIC", "DECIMAL":
+		// NUMERIC elements keep their exact text: the engine's float64 carrier
+		// would lose digits above 2^53 and trailing zeros.
+		return arrayElementNumeric, nil
+	case "BOOLEAN", "BOOL":
+		return arrayElementBoolean, nil
+	default:
+		return arrayElementText, pgerror.Unsupported("arrays of %s are not supported; supported element types are TEXT, VARCHAR, INTEGER, BIGINT, SMALLINT, BOOLEAN, REAL, DOUBLE PRECISION, and NUMERIC", strings.ToUpper(name))
+	}
+}
+
+// parseArrayLiteral splits PostgreSQL array-literal text into element strings.
+// A nil element is SQL NULL. Nested braces (multidimensional arrays) are
+// rejected, as is malformed quoting.
+func parseArrayLiteral(text string) ([]*string, error) {
+	s := strings.TrimSpace(text)
+	if len(s) < 2 || s[0] != '{' || s[len(s)-1] != '}' {
+		return nil, invalidArrayInput(text, "array text must be enclosed in {}")
+	}
+	inner := s[1 : len(s)-1]
+	if strings.TrimSpace(inner) == "" {
+		return nil, nil
+	}
+
+	var elements []*string
+	i := 0
+	for {
+		for i < len(inner) && isArraySpace(inner[i]) {
+			i++
+		}
+		if i >= len(inner) {
+			return nil, invalidArrayInput(text, "missing } after comma")
+		}
+		var value *string
+		if inner[i] == '"' {
+			i++
+			var b strings.Builder
+			closed := false
+			for i < len(inner) {
+				c := inner[i]
+				if c == '\\' {
+					if i+1 >= len(inner) {
+						return nil, invalidArrayInput(text, "trailing backslash in quoted element")
+					}
+					b.WriteByte(inner[i+1])
+					i += 2
+					continue
+				}
+				if c == '"' {
+					i++
+					closed = true
+					break
+				}
+				b.WriteByte(c)
+				i++
+			}
+			if !closed {
+				return nil, invalidArrayInput(text, "unterminated quoted element")
+			}
+			quoted := b.String()
+			value = &quoted
+			for i < len(inner) && isArraySpace(inner[i]) {
+				i++
+			}
+		} else {
+			if inner[i] == '{' {
+				return nil, pgerror.Unsupported("multidimensional arrays are not supported; array text must be one-dimensional")
+			}
+			start := i
+			for i < len(inner) && inner[i] != ',' {
+				if inner[i] == '{' || inner[i] == '}' || inner[i] == '"' {
+					return nil, invalidArrayInput(text, "malformed array element")
+				}
+				i++
+			}
+			raw := strings.TrimSpace(inner[start:i])
+			if raw == "" {
+				return nil, invalidArrayInput(text, "empty array element")
+			}
+			if !strings.EqualFold(raw, "NULL") {
+				value = &raw
+			}
+		}
+
+		elements = append(elements, value)
+		if i >= len(inner) {
+			return elements, nil
+		}
+		if inner[i] != ',' {
+			return nil, invalidArrayInput(text, "expected , between elements")
+		}
+		i++
+		if i >= len(inner) {
+			return nil, invalidArrayInput(text, "trailing comma")
+		}
+	}
+}
+
+func isArraySpace(c byte) bool {
+	return c == ' ' || c == '\t' || c == '\n' || c == '\r'
+}
+
+// invalidArrayInput reports malformed array text as a client input error.
+func invalidArrayInput(text, reason string) error {
+	return pgerror.InvalidText("invalid array input %q: %s", text, reason)
+}
+
+// formatArrayLiteral renders elements in canonical array text.
+func formatArrayLiteral(elements []*string) string {
+	var b strings.Builder
+	b.WriteByte('{')
+	for i, element := range elements {
+		if i > 0 {
+			b.WriteByte(',')
+		}
+		if element == nil {
+			b.WriteString("NULL")
+			continue
+		}
+		b.WriteString(formatArrayElementText(*element))
+	}
+	b.WriteByte('}')
+	return b.String()
+}
+
+// formatArrayElementText quotes an element when leaving it bare would change its
+// meaning: empty text, the NULL keyword, or characters that delimit elements.
+func formatArrayElementText(text string) string {
+	needsQuote := text == "" || strings.EqualFold(text, "NULL")
+	for i := 0; i < len(text) && !needsQuote; i++ {
+		switch text[i] {
+		case '{', '}', ',', '"', '\\', ' ', '\t', '\n', '\r':
+			needsQuote = true
+		}
+	}
+	if !needsQuote {
+		return text
+	}
+	var b strings.Builder
+	b.WriteByte('"')
+	for i := 0; i < len(text); i++ {
+		if text[i] == '"' || text[i] == '\\' {
+			b.WriteByte('\\')
+		}
+		b.WriteByte(text[i])
+	}
+	b.WriteByte('"')
+	return b.String()
+}
+
+// CanonicalizeArrayText validates PostgreSQL array text for an array type and
+// returns its canonical encoding. The protocol layer uses it to reject a
+// malformed bound array parameter at Bind time instead of waiting for a row to
+// evaluate the predicate.
+func CanonicalizeArrayText(text, typeName string) (string, error) {
+	return canonicalArrayText(text, typeName)
+}
+
+// canonicalArrayText parses array text and re-encodes it for the given array
+// type, converting each element to its canonical spelling.
+func canonicalArrayText(text, typeName string) (string, error) {
+	kind, err := arrayElementKindOf(typeName)
+	if err != nil {
+		return "", err
+	}
+	elements, err := parseArrayLiteral(text)
+	if err != nil {
+		return "", err
+	}
+	canonical := make([]*string, len(elements))
+	for i, element := range elements {
+		value, elementErr := canonicalArrayElement(element, kind, typeName)
+		if elementErr != nil {
+			return "", elementErr
+		}
+		canonical[i] = value
+	}
+	return formatArrayLiteral(canonical), nil
+}
+
+// canonicalArrayElement converts one element to its canonical text. Element
+// types the engine does not carry exactly (integer widths, floating point) are
+// parsed and re-formatted so equivalent inputs encode identically.
+func canonicalArrayElement(element *string, kind arrayElementKind, typeName string) (*string, error) {
+	if element == nil {
+		return nil, nil
+	}
+	raw := *element
+	switch kind {
+	case arrayElementInteger:
+		n, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
+		if err != nil {
+			return nil, pgerror.InvalidText("invalid input syntax for type %s: %q", typeName, raw)
+		}
+		canonical := strconv.FormatInt(n, 10)
+		return &canonical, nil
+	case arrayElementReal:
+		f, err := strconv.ParseFloat(strings.TrimSpace(raw), 64)
+		if err != nil {
+			return nil, pgerror.InvalidText("invalid input syntax for type %s: %q", typeName, raw)
+		}
+		canonical := strconv.FormatFloat(f, 'g', -1, 64)
+		return &canonical, nil
+	case arrayElementNumeric:
+		canonical, ok := normalizeNumericArrayElement(raw)
+		if !ok {
+			return nil, pgerror.InvalidText("invalid input syntax for type %s: %q", typeName, raw)
+		}
+		return &canonical, nil
+	case arrayElementBoolean:
+		value, ok := parseArrayBoolean(raw)
+		if !ok {
+			return nil, pgerror.InvalidText("invalid input syntax for type %s: %q", typeName, raw)
+		}
+		canonical := "f"
+		if value {
+			canonical = "t"
+		}
+		return &canonical, nil
+	default:
+		canonical := raw
+		return &canonical, nil
+	}
+}
+
+// parseArrayBoolean accepts the boolean spellings PostgreSQL accepts in a text
+// array element and reports the value.
+func parseArrayBoolean(raw string) (bool, bool) {
+	switch strings.ToLower(strings.TrimSpace(raw)) {
+	case "t", "true", "1", "y", "yes", "on":
+		return true, true
+	case "f", "false", "0", "n", "no", "off":
+		return false, true
+	default:
+		return false, false
+	}
+}
+
+// arrayElementValue converts canonical element text to the Go value the engine
+// uses for that element type. Text elements stay strings.
+func arrayElementValue(element *string, elementType string) (interface{}, error) {
+	if element == nil {
+		return nil, nil
+	}
+	kind, err := arrayElementKindForName(elementType)
+	if err != nil {
+		return nil, err
+	}
+	raw := *element
+	switch kind {
+	case arrayElementInteger:
+		n, err := strconv.ParseInt(raw, 10, 64)
+		if err != nil {
+			return nil, pgerror.InvalidText("invalid input syntax for type integer: %q", raw)
+		}
+		return n, nil
+	case arrayElementReal:
+		f, err := strconv.ParseFloat(raw, 64)
+		if err != nil {
+			return nil, pgerror.InvalidText("invalid input syntax for type double precision: %q", raw)
+		}
+		return f, nil
+	case arrayElementNumeric:
+		canonical, ok := normalizeNumericArrayElement(raw)
+		if !ok {
+			return nil, pgerror.InvalidText("invalid input syntax for type numeric: %q", raw)
+		}
+		// Return the text so a value above 2^53 keeps every digit.
+		return canonical, nil
+	case arrayElementBoolean:
+		value, ok := parseArrayBoolean(raw)
+		if !ok {
+			return nil, pgerror.InvalidText("invalid input syntax for type boolean: %q", raw)
+		}
+		return value, nil
+	default:
+		return raw, nil
+	}
+}
+
+// normalizeNumericArrayElement validates SQL NUMERIC syntax and returns the
+// canonical spelling: surrounding whitespace trimmed and a leading + removed.
+// Go's ParseFloat is not used because it also accepts hex floats, NaN, and Inf,
+// which NUMERIC has no representation for here.
+func normalizeNumericArrayElement(raw string) (string, bool) {
+	text := strings.TrimSpace(raw)
+	if text == "" {
+		return "", false
+	}
+	if text[0] == '+' {
+		text = text[1:]
+	}
+	if text == "" {
+		return "", false
+	}
+	i := 0
+	if text[i] == '-' {
+		i++
+	}
+	digits := 0
+	for i < len(text) && text[i] >= '0' && text[i] <= '9' {
+		i++
+		digits++
+	}
+	if i < len(text) && text[i] == '.' {
+		i++
+		for i < len(text) && text[i] >= '0' && text[i] <= '9' {
+			i++
+			digits++
+		}
+	}
+	if digits == 0 {
+		return "", false
+	}
+	if i < len(text) && (text[i] == 'e' || text[i] == 'E') {
+		i++
+		if i < len(text) && (text[i] == '+' || text[i] == '-') {
+			i++
+		}
+		exponentDigits := 0
+		for i < len(text) && text[i] >= '0' && text[i] <= '9' {
+			i++
+			exponentDigits++
+		}
+		if exponentDigits == 0 {
+			return "", false
+		}
+	}
+	if i != len(text) {
+		return "", false
+	}
+	return text, true
+}
+
+// numericValueRat converts an evaluated value to an exact rational. Strings and
+// floats use their decimal spelling, so a column value "1.10" and the float
+// literal 1.1 both compare as 11/10 rather than through binary rounding.
+func numericValueRat(value interface{}) (*big.Rat, bool) {
+	var text string
+	switch v := value.(type) {
+	case string:
+		normalized, ok := normalizeNumericArrayElement(v)
+		if !ok {
+			return nil, false
+		}
+		text = normalized
+	case int64:
+		return new(big.Rat).SetInt64(v), true
+	case int:
+		return new(big.Rat).SetInt64(int64(v)), true
+	case float64:
+		text = strconv.FormatFloat(v, 'g', -1, 64)
+	default:
+		return nil, false
+	}
+	rat, ok := new(big.Rat).SetString(text)
+	return rat, ok
+}
+
+// compareNumericValues compares two values exactly when both are numeric.
+func compareNumericValues(left, right interface{}) (int, bool) {
+	leftRat, ok := numericValueRat(left)
+	if !ok {
+		return 0, false
+	}
+	rightRat, ok := numericValueRat(right)
+	if !ok {
+		return 0, false
+	}
+	return leftRat.Cmp(rightRat), true
+}
+
+// numericArrayTextEqual reports whether two canonical NUMERIC array texts are
+// equal element-wise, with NULL elements equal to each other. Other array types
+// compare by their canonical bytes.
+func numericArrayTextEqual(left, right, typeName string) (bool, error) {
+	kind, err := arrayElementKindOf(typeName)
+	if err != nil {
+		return false, err
+	}
+	if kind != arrayElementNumeric {
+		return left == right, nil
+	}
+	leftElements, err := parseArrayLiteral(left)
+	if err != nil {
+		return false, err
+	}
+	rightElements, err := parseArrayLiteral(right)
+	if err != nil {
+		return false, err
+	}
+	if len(leftElements) != len(rightElements) {
+		return false, nil
+	}
+	for i := range leftElements {
+		leftElement, rightElement := leftElements[i], rightElements[i]
+		if leftElement == nil || rightElement == nil {
+			if leftElement != nil || rightElement != nil {
+				return false, nil
+			}
+			continue
+		}
+		cmp, ok := compareNumericValues(*leftElement, *rightElement)
+		if !ok {
+			return false, pgerror.InvalidText("invalid input syntax for type numeric: %q", *leftElement)
+		}
+		if cmp != 0 {
+			return false, nil
+		}
+	}
+	return true, nil
+}
+
+// arrayValueElement renders one evaluated expression value as a canonical array
+// element. Array nesting and non-scalar values are rejected.
+func arrayValueElement(value interface{}) (*string, error) {
+	switch v := value.(type) {
+	case nil:
+		return nil, nil
+	case bool:
+		text := "f"
+		if v {
+			text = "t"
+		}
+		return &text, nil
+	case int64:
+		text := strconv.FormatInt(v, 10)
+		return &text, nil
+	case int:
+		text := strconv.FormatInt(int64(v), 10)
+		return &text, nil
+	case float64:
+		text := strconv.FormatFloat(v, 'g', -1, 64)
+		return &text, nil
+	case string:
+		return &v, nil
+	case []byte:
+		return nil, pgerror.Unsupported("arrays of BLOB are not supported")
+	default:
+		return nil, pgerror.Unsupported("ARRAY[...] element type %T is not supported", value)
+	}
+}
+
+// evalArrayExpr evaluates the ARRAY[...] constructor into canonical array text.
+func (e *Executor) evalArrayExpr(expr *parser.ArrayExpr, row storage.Row) (interface{}, error) {
+	elements := make([]*string, len(expr.Elements))
+	for i, element := range expr.Elements {
+		value, err := e.evalExpr(element, row)
+		if err != nil {
+			return nil, err
+		}
+		text, err := arrayValueElement(value)
+		if err != nil {
+			return nil, fmt.Errorf("ARRAY element %d: %w", i+1, err)
+		}
+		elements[i] = text
+	}
+	return formatArrayLiteral(elements), nil
+}
+
+// arrayExpressionIsConstant reports whether evaluating an array expression once
+// per statement is safe: it references no columns and no volatile function.
+func arrayExpressionIsConstant(expr parser.Expr) bool {
+	if expr == nil || len(collectColumnRefs(expr)) != 0 {
+		return false
+	}
+	return ValidateDeterministicExpr(expr) == nil
+}
+
+// arrayOperand evaluates an array-valued expression through the per-statement
+// value cache. A constant operand is evaluated once for the whole scan instead
+// of once per row; a column-dependent operand is re-evaluated every time.
+func (e *Executor) arrayOperand(expr parser.Expr, evaluate func() (interface{}, error)) (interface{}, error) {
+	if e.arrayValueCache != nil && expr != nil {
+		if value, ok := e.arrayValueCache[expr]; ok {
+			return value, nil
+		}
+	}
+	value, err := evaluate()
+	if err != nil {
+		return nil, err
+	}
+	if e.arrayValueCache != nil && expr != nil && arrayExpressionIsConstant(expr) {
+		e.arrayValueCache[expr] = value
+	}
+	return value, nil
+}
+
+// arrayParseEntry memoizes the parsed elements of one array expression for the
+// current statement. The evaluated text is compared before a hit is used, so a
+// value that changes between rows or between executions is re-parsed.
+type arrayParseEntry struct {
+	text     string
+	elements []*string
+	err      error
+}
+
+// parsedArray parses an evaluated array value for one expression. The
+// per-statement cache keyed by AST node keeps `= ANY($1)` to one parse for the
+// whole scan instead of one parse per row.
+func (e *Executor) parsedArray(expr parser.Expr, value interface{}) ([]*string, error) {
+	text := toString(value)
+	if e.arrayCache != nil && expr != nil {
+		if entry, ok := e.arrayCache[expr]; ok && entry.text == text {
+			return entry.elements, entry.err
+		}
+	}
+	elements, err := parseArrayLiteral(text)
+	if e.arrayCache != nil && expr != nil {
+		e.arrayCache[expr] = &arrayParseEntry{text: text, elements: elements, err: err}
+	}
+	return elements, err
+}
+
+// isNumericExprType reports whether an expression's declared type is NUMERIC or
+// DECIMAL, which compares exactly instead of through float64. CAST(... AS
+// NUMERIC) is included even though its result metadata stays unchanged: the
+// cast leaves the value's carrier alone, but comparisons still mean decimal.
+func (e *Executor) isNumericExprType(expr parser.Expr) bool {
+	switch x := expr.(type) {
+	case nil:
+		return false
+	case *parser.ParenExpr:
+		return e.isNumericExprType(x.Expr)
+	case *parser.CastExpr:
+		switch strings.ToUpper(strings.TrimSpace(x.Type.Name)) {
+		case "NUMERIC", "DECIMAL":
+			return true
+		}
+		return false
+	}
+	switch strings.ToUpper(strings.TrimSpace(inferExprType(expr, e.resolveColumnType))) {
+	case "NUMERIC", "DECIMAL":
+		return true
+	default:
+		return false
+	}
+}
+
+// comparisonOperatorResult applies a comparison operator to a three-way result.
+func comparisonOperatorResult(op lexer.TokenType, cmp int) (bool, error) {
+	switch op {
+	case lexer.TokenEq:
+		return cmp == 0, nil
+	case lexer.TokenNeq:
+		return cmp != 0, nil
+	case lexer.TokenLt:
+		return cmp < 0, nil
+	case lexer.TokenLte:
+		return cmp <= 0, nil
+	case lexer.TokenGt:
+		return cmp > 0, nil
+	case lexer.TokenGte:
+		return cmp >= 0, nil
+	default:
+		return false, fmt.Errorf("unsupported comparison operator: %v", op)
+	}
+}
+
+// arrayEqualityType reports the array type used for a whole-array equality
+// comparison. The left side wins when both are arrays.
+func (e *Executor) arrayEqualityType(leftExpr, rightExpr parser.Expr) (string, bool) {
+	if typeName, ok := e.arrayTypeOf(leftExpr); ok {
+		return typeName, true
+	}
+	return e.arrayTypeOf(rightExpr)
+}
+
+// compareExprValues applies a comparison operator to two evaluated values. A
+// 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 op == lexer.TokenEq || op == lexer.TokenNeq {
+		if typeName, ok := e.arrayEqualityType(leftExpr, rightExpr); ok {
+			equal, err := numericArrayTextEqual(toString(left), toString(right), typeName)
+			if err != nil {
+				return false, err
+			}
+			if op == lexer.TokenNeq {
+				return !equal, nil
+			}
+			return equal, nil
+		}
+	}
+	if e.isNumericExprType(leftExpr) || e.isNumericExprType(rightExpr) {
+		if cmp, ok := compareNumericValues(left, right); ok {
+			return comparisonOperatorResult(op, cmp)
+		}
+	}
+	return comparisonOperatorResult(op, compare(left, right))
+}
+
+// arrayElementTypeOf reports the element type of an expression whose value is an
+// array, resolved against the declared column types of the query currently
+// executing. An unknown type (a derived table, a bare literal) reports false so
+// the caller keeps the element text.
+func (e *Executor) arrayElementTypeOf(expr parser.Expr) (string, bool) {
+	if expr == nil {
+		return "", false
+	}
+	return arrayElementType(inferExprType(expr, e.resolveColumnType))
+}
+
+// arrayIndexValue validates an array subscript or array_length dimension.
+// PostgreSQL's signatures take integers; floats, booleans, and text are a
+// datatype mismatch, not an implicit truncation.
+func arrayIndexValue(value interface{}, what string) (int64, error) {
+	switch v := value.(type) {
+	case int64:
+		return v, nil
+	case int:
+		return int64(v), nil
+	case int32:
+		return int64(v), nil
+	case int16:
+		return int64(v), nil
+	case int8:
+		return int64(v), nil
+	default:
+		return 0, pgerror.DatatypeMismatch("%s must be an integer, got %T", what, value)
+	}
+}
+
+// evalSubscriptExpr evaluates arr[index] with PostgreSQL's 1-based indexing. An
+// out-of-range subscript yields NULL, and a NULL array, index, or element yields
+// NULL. The element is decoded to the array's declared element type so a
+// boolean subscript is a Go bool rather than the canonical t/f text.
+func (e *Executor) evalSubscriptExpr(expr *parser.SubscriptExpr, row storage.Row) (interface{}, error) {
+	value, err := e.arrayOperand(expr.Expr, func() (interface{}, error) {
+		return e.evalExpr(expr.Expr, row)
+	})
+	if err != nil {
+		return nil, err
+	}
+	index, err := e.evalExpr(expr.Index, row)
+	if err != nil {
+		return nil, err
+	}
+	return e.subscriptArrayValue(expr.Expr, value, index)
+}
+
+// subscriptArrayValue applies a 1-based subscript to an evaluated array value.
+func (e *Executor) subscriptArrayValue(arrayExpr parser.Expr, value, index interface{}) (interface{}, error) {
+	if value == nil || index == nil {
+		return nil, nil
+	}
+	position, err := arrayIndexValue(index, "array subscript")
+	if err != nil {
+		return nil, err
+	}
+	elements, err := e.parsedArray(arrayExpr, value)
+	if err != nil {
+		return nil, fmt.Errorf("array subscript requires an array value: %w", err)
+	}
+	if position < 1 || position > int64(len(elements)) {
+		return nil, nil
+	}
+	element := elements[position-1]
+	if element == nil {
+		return nil, nil
+	}
+	elementType, ok := e.arrayElementTypeOf(arrayExpr)
+	if !ok {
+		return *element, nil
+	}
+	return arrayElementValue(element, elementType)
+}
+
+// evalAnyAllExpr evaluates `left op ANY(array)` / `left op ALL(array)` with SQL
+// three-valued logic. ANY is true when any comparison is true, false when none
+// is true and no comparison is NULL, and NULL otherwise; ALL is its dual.
+func (e *Executor) evalAnyAllExpr(expr *parser.AnyAllExpr, row storage.Row) (interface{}, error) {
+	left, err := e.evalExpr(expr.Left, row)
+	if err != nil {
+		return nil, err
+	}
+	arrayValue, err := e.arrayOperand(expr.Array, func() (interface{}, error) {
+		return e.evalExpr(expr.Array, row)
+	})
+	if err != nil {
+		return nil, err
+	}
+	if arrayValue == nil {
+		return nil, nil
+	}
+	elements, err := e.parsedArray(expr.Array, arrayValue)
+	if err != nil {
+		return nil, fmt.Errorf("ANY/ALL requires an array operand: %w", err)
+	}
+	elementType, _ := e.arrayElementTypeOf(expr.Array)
+	return evalQuantifiedComparison(expr.Op, expr.All, left, elements, elementType)
+}
+
+// evalQuantifiedComparison applies a comparison to each array element. The
+// empty array is decided before the left operand: ANY over no elements is
+// false and ALL over no elements is true, even when the left operand is NULL.
+func evalQuantifiedComparison(op lexer.TokenType, all bool, left interface{}, elements []*string, elementType string) (interface{}, error) {
+	if len(elements) == 0 {
+		return all, nil
+	}
+	if left == nil {
+		return nil, nil
+	}
+	sawNull := false
+	for _, element := range elements {
+		if element == nil {
+			sawNull = true
+			continue
+		}
+		matched, err := compareArrayElement(op, left, *element, elementType)
+		if err != nil {
+			return nil, err
+		}
+		if all && !matched {
+			return false, nil
+		}
+		if !all && matched {
+			return true, nil
+		}
+	}
+	if sawNull {
+		return nil, nil
+	}
+	return all, nil
+}
+
+// compareArrayElement applies a comparison operator between a scalar left value
+// and one array element's text. Booleans are decoded so `true = ANY('{t,f}')`
+// compares as booleans; every other type compares through the engine's numeric
+// or textual comparison rules.
+func compareArrayElement(op lexer.TokenType, left interface{}, element, elementType string) (bool, error) {
+	if kind, err := arrayElementKindForName(elementType); err == nil && kind == arrayElementNumeric {
+		cmp, ok := compareNumericValues(left, element)
+		if !ok {
+			return false, pgerror.InvalidText("invalid input syntax for type numeric: %q", element)
+		}
+		return comparisonOperatorResult(op, cmp)
+	}
+	var right interface{} = element
+	if _, isBool := left.(bool); isBool {
+		value, ok := parseArrayBoolean(element)
+		if !ok {
+			return false, pgerror.InvalidText("invalid input syntax for type boolean: %q", element)
+		}
+		right = value
+	}
+	return comparisonOperatorResult(op, compare(left, right))
+}
+
+// evalArrayLength implements array_length(array, dimension). Only dimension 1 is
+// meaningful for a one-dimensional array; any other dimension yields NULL, as
+// PostgreSQL does for an out-of-range dimension of a non-nested array.
+func (e *Executor) evalArrayLength(arrayExpr parser.Expr, args []interface{}) (interface{}, error) {
+	if len(args) != 2 {
+		return nil, fmt.Errorf("array_length requires two arguments")
+	}
+	if args[0] == nil || args[1] == nil {
+		return nil, nil
+	}
+	dimension, err := arrayIndexValue(args[1], "array dimension")
+	if err != nil {
+		return nil, err
+	}
+	if dimension != 1 {
+		return nil, nil
+	}
+	elements, err := e.parsedArray(arrayExpr, args[0])
+	if err != nil {
+		return nil, fmt.Errorf("array_length requires an array value: %w", err)
+	}
+	if len(elements) == 0 {
+		// PostgreSQL reports NULL for the length of an empty array.
+		return nil, nil
+	}
+	return int64(len(elements)), nil
+}
+
+// arrayTypeOf reports the declared array type of an expression, if any.
+func (e *Executor) arrayTypeOf(expr parser.Expr) (string, bool) {
+	if expr == nil {
+		return "", false
+	}
+	typeName := inferExprType(expr, e.resolveColumnType)
+	if _, ok := arrayElementType(typeName); !ok {
+		return "", false
+	}
+	return typeName, true
+}
+
+// arrayCanonicalEntry memoizes one canonicalized array comparison operand for
+// the current statement, validated by the source text and target type.
+type arrayCanonicalEntry struct {
+	text      string
+	typeName  string
+	canonical string
+	err       error
+}
+
+// canonicalArrayValue canonicalizes a value against the array type of expr. It
+// is a no-op for non-array expressions, NULL, and non-string values. The result
+// is memoized per AST node and source text, so a constant literal on one side of
+// a scan predicate is canonicalized once per statement.
+func (e *Executor) canonicalArrayValue(arrayExpr parser.Expr, value interface{}) (interface{}, error) {
+	typeName, ok := e.arrayTypeOf(arrayExpr)
+	if !ok || value == nil {
+		return value, nil
+	}
+	text, isText := value.(string)
+	if !isText {
+		return value, nil
+	}
+	if e.arrayCanonicalCache != nil && arrayExpr != nil {
+		if entry, cached := e.arrayCanonicalCache[arrayExpr]; cached && entry.text == text && entry.typeName == typeName {
+			if entry.err != nil {
+				return nil, entry.err
+			}
+			return entry.canonical, nil
+		}
+	}
+	canonical, err := canonicalArrayText(text, typeName)
+	if e.arrayCanonicalCache != nil && arrayExpr != nil {
+		e.arrayCanonicalCache[arrayExpr] = &arrayCanonicalEntry{text: text, typeName: typeName, canonical: canonical, err: err}
+	}
+	if err != nil {
+		return nil, err
+	}
+	return canonical, nil
+}
+
+// canonicalizeArrayComparison canonicalizes the string operand of a comparison
+// when the other operand is array-typed, so a text literal such as '{a, b}' or
+// '{"a","b"}' compares equal to the canonical stored '{a,b}'.
+func (e *Executor) canonicalizeArrayComparison(leftExpr, rightExpr parser.Expr, left, right interface{}) (interface{}, interface{}, error) {
+	if _, ok := e.arrayTypeOf(leftExpr); ok {
+		canonical, err := e.canonicalArrayValue(leftExpr, right)
+		if err != nil {
+			return nil, nil, err
+		}
+		right = canonical
+	}
+	if _, ok := e.arrayTypeOf(rightExpr); ok {
+		canonical, err := e.canonicalArrayValue(rightExpr, left)
+		if err != nil {
+			return nil, nil, err
+		}
+		left = canonical
+	}
+	return left, right, 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.
+func evalArrayCast(target parser.DataType, value interface{}) (interface{}, error) {
+	if value == nil {
+		return nil, nil
+	}
+	text, ok := value.(string)
+	if !ok {
+		return nil, pgerror.InvalidText("cannot cast %T to %s", value, target.SQLTypeName())
+	}
+	return canonicalArrayText(text, target.SQLTypeName())
+}
+
+// canonicalArrayDefault canonicalizes a constant DEFAULT for an array column so
+// the catalog and any ALTER TABLE backfill store the same bytes an INSERT would.
+func canonicalArrayDefault(typeName string, value interface{}, exprText string) (interface{}, string, error) {
+	if _, isArray := arrayElementType(typeName); !isArray || exprText != "" || value == nil {
+		return value, exprText, nil
+	}
+	text, ok := value.(string)
+	if !ok {
+		return nil, "", pgerror.InvalidText("DEFAULT for %s must be array text, got %T", typeName, value)
+	}
+	canonical, err := canonicalArrayText(text, typeName)
+	if err != nil {
+		return nil, "", err
+	}
+	return canonical, "", nil
+}
+
+// normalizeArrayRow canonicalizes every array-typed column value in a row before
+// it is written. A string value is parsed as array text; an already-canonical
+// value is re-encoded identically, so the encoding is stable across reloads and
+// every write path shares one representation.
+func (e *Executor) normalizeArrayRow(schema *storage.Schema, row storage.Row) error {
+	if schema == nil {
+		return nil
+	}
+	for _, column := range schema.Columns {
+		if _, isArray := arrayElementType(column.Type); !isArray {
+			continue
+		}
+		value, ok := lookupRowValue(row, column.Name)
+		if !ok || value == nil {
+			continue
+		}
+		text, isText := value.(string)
+		if !isText {
+			return pgerror.InvalidText("column %q is %s and accepts array text, got %T", column.Name, column.Type, value)
+		}
+		canonical, err := canonicalArrayText(text, column.Type)
+		if err != nil {
+			return fmt.Errorf("column %q: %w", column.Name, err)
+		}
+		row[column.Name] = canonical
+	}
+	return nil
+}

+ 971 - 0
pkg/executor/arrays_test.go

@@ -0,0 +1,971 @@
+package executor
+
+import (
+	"errors"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/danfragoso/pizzasql-next/pkg/analyzer"
+	"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"
+)
+
+func TestArrayColumnDDLAndRoundTrip(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, `CREATE TABLE items (
+		id INTEGER PRIMARY KEY,
+		tags TEXT[] NOT NULL,
+		scores INTEGER[],
+		flags BOOLEAN[],
+		ratios DOUBLE PRECISION[]
+	)`)
+	execMust(t, e, `INSERT INTO items (id, tags, scores, flags, ratios)
+		VALUES (1, '{a,b}', '{1,2}', '{t,f}', '{1.5,2.25}')`)
+	execMust(t, e, `INSERT INTO items (id, tags, scores)
+		VALUES (2, '{}', NULL)`)
+
+	res := execMust(t, e, `SELECT id, tags, scores, flags, ratios FROM items ORDER BY id`)
+	if got := res.Rows[0][1]; got != "{a,b}" {
+		t.Fatalf("tags = %v, want {a,b}", got)
+	}
+	// Elements are canonicalized per declared type.
+	if got := res.Rows[0][2]; got != "{1,2}" {
+		t.Fatalf("scores = %v, want {1,2}", got)
+	}
+	if got := res.Rows[0][3]; got != "{t,f}" {
+		t.Fatalf("flags = %v, want {t,f}", got)
+	}
+	if got := res.Rows[0][4]; got != "{1.5,2.25}" {
+		t.Fatalf("ratios = %v, want {1.5,2.25}", got)
+	}
+	// NULL and the empty array stay distinguishable.
+	if got := res.Rows[1][1]; got != "{}" {
+		t.Fatalf("empty tags = %v, want {}", got)
+	}
+	if got := res.Rows[1][2]; got != nil {
+		t.Fatalf("NULL scores = %v, want nil", got)
+	}
+
+	// Result metadata reports the declared array type.
+	if got := res.ColumnTypes[1]; got != "TEXT[]" {
+		t.Fatalf("tags column type = %q, want TEXT[]", got)
+	}
+
+	// The durable catalog keeps the array spelling, so sqlite_master and
+	// PRAGMA table_info still render it.
+	master := execMust(t, e, `SELECT sql FROM sqlite_master WHERE name = 'items'`)
+	if sql, _ := master.Rows[0][0].(string); !strings.Contains(sql, "TEXT[]") {
+		t.Fatalf("sqlite_master SQL = %q, want it to contain TEXT[]", sql)
+	}
+	info := execMust(t, e, `PRAGMA table_info(items)`)
+	found := false
+	for _, row := range info.Rows {
+		if row[1] == "scores" {
+			found = true
+			if row[2] != "INTEGER[]" {
+				t.Fatalf("PRAGMA table_info type = %v, want INTEGER[]", row[2])
+			}
+		}
+	}
+	if !found {
+		t.Fatal("PRAGMA table_info did not report the scores column")
+	}
+
+	// A fresh executor over the same store sees the same bytes: no reload
+	// conversion and no storage-format change.
+	reloaded := newExec(schema, table)
+	res = execMust(t, reloaded, `SELECT tags, scores, flags FROM items WHERE id = 1`)
+	if res.Rows[0][0] != "{a,b}" || res.Rows[0][1] != "{1,2}" || res.Rows[0][2] != "{t,f}" {
+		t.Fatalf("reloaded row = %v", res.Rows[0])
+	}
+}
+
+func TestArrayCanonicalEncoding(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, `CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT[])`)
+
+	for _, tc := range []struct{ sql, want string }{
+		{`INSERT INTO t VALUES (1, '{ a , b }') RETURNING v`, "{a,b}"},
+		{`INSERT INTO t VALUES (2, '{"a b","c,d"}') RETURNING v`, `{"a b","c,d"}`},
+		{`INSERT INTO t VALUES (3, ARRAY['x', NULL, 'NULL']) RETURNING v`, `{x,NULL,"NULL"}`},
+		{`INSERT INTO t VALUES (4, ARRAY[]::TEXT[]) RETURNING v`, "{}"},
+		{`INSERT INTO t VALUES (5, '{"quote\"d", "back\\slash"}') RETURNING v`, `{"quote\"d","back\\slash"}`},
+	} {
+		res := execMust(t, e, tc.sql)
+		if got := res.Rows[0][0]; got != tc.want {
+			t.Errorf("%s = %v, want %s", tc.sql, got, tc.want)
+		}
+	}
+}
+
+func TestArrayConstructorAndCast(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+
+	cases := []struct {
+		sql  string
+		want interface{}
+	}{
+		{`SELECT ARRAY[1, 2, 3]`, "{1,2,3}"},
+		{`SELECT ARRAY['a', NULL]`, "{a,NULL}"},
+		{`SELECT ARRAY[TRUE, FALSE]`, "{t,f}"},
+		{`SELECT '{1,2,3}'::INTEGER[]`, "{1,2,3}"},
+		{`SELECT CAST('{1,2}' AS INTEGER[])`, "{1,2}"},
+		{`SELECT ARRAY['1','2']::INTEGER[]`, "{1,2}"},
+		{`SELECT '{1.50,2}'::NUMERIC[]`, "{1.50,2}"},
+		{`SELECT NULL::INTEGER[]`, nil},
+	}
+	for _, tc := range cases {
+		res := execMust(t, e, tc.sql)
+		if got := res.Rows[0][0]; got != tc.want {
+			t.Errorf("%s = %#v, want %#v", tc.sql, got, tc.want)
+		}
+	}
+
+	res := execMust(t, e, `SELECT ARRAY[1,2] AS a, '{x}'::TEXT[] AS b`)
+	if res.ColumnTypes[0] != "INTEGER[]" || res.ColumnTypes[1] != "TEXT[]" {
+		t.Fatalf("constructor types = %v, want [INTEGER[] TEXT[]]", res.ColumnTypes)
+	}
+}
+
+func TestArraySubscriptAndLength(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[])`)
+	execMust(t, e, `INSERT INTO t VALUES (1, '{10,20,30}', '{"x", NULL, "z"}', '{t,f}')`)
+	execMust(t, e, `INSERT INTO t VALUES (2, '{}', '{}', '{}')`)
+
+	cases := []struct {
+		sql  string
+		want interface{}
+	}{
+		{`SELECT nums[1] FROM t WHERE id = 1`, int64(10)},
+		{`SELECT nums[3] FROM t WHERE id = 1`, int64(30)},
+		{`SELECT nums[4] FROM t WHERE id = 1`, nil},
+		{`SELECT nums[0] FROM t WHERE id = 1`, nil},
+		{`SELECT tags[2] FROM t WHERE id = 1`, nil},
+		{`SELECT tags[1] FROM t WHERE id = 1`, "x"},
+		{`SELECT flags[1] FROM t WHERE id = 1`, true},
+		{`SELECT flags[2] FROM t WHERE id = 1`, false},
+		{`SELECT nums[1] FROM t WHERE id = 2`, nil},
+		{`SELECT array_length(nums, 1) FROM t WHERE id = 1`, int64(3)},
+		{`SELECT array_length(nums, 1) FROM t WHERE id = 2`, nil},
+		{`SELECT array_length(nums, 2) FROM t WHERE id = 1`, nil},
+	}
+	for _, tc := range cases {
+		res := execMust(t, e, tc.sql)
+		if got := res.Rows[0][0]; got != tc.want {
+			t.Errorf("%s = %#v, want %#v", tc.sql, got, tc.want)
+		}
+	}
+
+	res := execMust(t, e, `SELECT nums[1] AS n FROM t WHERE id = 1`)
+	if res.ColumnTypes[0] != "INTEGER" {
+		t.Fatalf("subscript type = %q, want INTEGER", res.ColumnTypes[0])
+	}
+}
+
+func TestAnyAllThreeValuedLogic(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, `CREATE TABLE t (id INTEGER PRIMARY KEY, n INTEGER, b BOOLEAN)`)
+	execMust(t, e, `INSERT INTO t VALUES (1, 2, TRUE)`)
+
+	cases := []struct {
+		sql  string
+		want interface{}
+	}{
+		// ANY: true if any comparison is true.
+		{`SELECT 2 = ANY('{1,2,3}')`, true},
+		{`SELECT 5 = ANY('{1,2,3}')`, false},
+		// NULL elements: no true and a NULL comparison yields NULL.
+		{`SELECT 5 = ANY('{1,NULL,3}')`, nil},
+		{`SELECT 2 = ANY('{NULL,2}')`, true},
+		// Empty array: ANY is false, ALL is true, even for a NULL left side.
+		{`SELECT 5 = ANY('{}')`, false},
+		{`SELECT NULL = ANY('{}')`, false},
+		{`SELECT 5 <> ALL('{}')`, true},
+		{`SELECT NULL <> ALL('{}')`, true},
+		// ALL: false if any comparison is false, NULL if only NULLs remain.
+		{`SELECT 2 <> ALL('{1,3,4}')`, true},
+		{`SELECT 2 <> ALL('{1,2,4}')`, false},
+		{`SELECT 2 <> ALL('{1,NULL}')`, nil},
+		{`SELECT 3 > ALL('{1,2}')`, true},
+		{`SELECT 1 >= ALL('{1,NULL}')`, nil},
+		// A NULL left operand yields NULL for a non-empty array.
+		{`SELECT NULL = ANY('{1,2}')`, nil},
+		{`SELECT NULL <> ALL('{1,2}')`, nil},
+		// Boolean elements compare as booleans, not as text.
+		{`SELECT TRUE = ANY('{f,t}')`, true},
+		{`SELECT FALSE = ANY('{f,t}')`, true},
+		{`SELECT TRUE <> ALL('{f,t}')`, false},
+		// Column-driven forms, including a NULL column.
+		{`SELECT n = ANY('{1,2}') FROM t`, true},
+		{`SELECT n <> ALL('{1,2}') FROM t`, false},
+		{`SELECT b = ANY('{t}') FROM t`, true},
+		{`SELECT id = ANY('{9,8}') FROM t`, false},
+	}
+	for _, tc := range cases {
+		res := execMust(t, e, tc.sql)
+		if got := res.Rows[0][0]; got != tc.want {
+			t.Errorf("%s = %#v, want %#v", tc.sql, got, tc.want)
+		}
+	}
+
+	if res := execMust(t, e, `SELECT n = ANY('{1,2}') AS hit FROM t`); res.ColumnTypes[0] != "BOOLEAN" {
+		t.Fatalf("ANY result type = %q, want BOOLEAN", res.ColumnTypes[0])
+	}
+}
+
+func TestAnyAllOverArrayColumn(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, `CREATE TABLE t (id INTEGER PRIMARY KEY, allowed TEXT[])`)
+	execMust(t, e, `INSERT INTO t VALUES (1, '{red,green}'), (2, '{blue}'), (3, NULL)`)
+
+	for _, tc := range []struct {
+		sql  string
+		want int
+	}{
+		{`SELECT count(*) FROM t WHERE 'red' = ANY(allowed)`, 1},
+		{`SELECT count(*) FROM t WHERE 'purple' = ANY(allowed)`, 0},
+		{`SELECT count(*) FROM t WHERE 'purple' <> ALL(allowed)`, 2},
+	} {
+		res := execMust(t, e, tc.sql)
+		if got := res.Rows[0][0]; got != int64(tc.want) {
+			t.Errorf("%s = %v, want %d", tc.sql, got, tc.want)
+		}
+	}
+}
+
+func TestUnnestAsTableFunction(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+
+	res := execMust(t, e, `SELECT * FROM unnest('{10,20,30}'::INTEGER[])`)
+	if res.RowCount != 3 {
+		t.Fatalf("unnest rows = %d, want 3", res.RowCount)
+	}
+	if res.Columns[0] != "unnest" {
+		t.Fatalf("unnest column = %q, want unnest", res.Columns[0])
+	}
+	if res.Rows[0][0] != int64(10) || res.Rows[2][0] != int64(30) {
+		t.Fatalf("unnest values = %v", res.Rows)
+	}
+	if res.ColumnTypes[0] != "INTEGER" {
+		t.Fatalf("unnest column type = %q, want INTEGER", res.ColumnTypes[0])
+	}
+
+	res = execMust(t, e, `SELECT u.unnest FROM unnest(ARRAY['a', 'b']) AS u WHERE u.unnest <> 'a'`)
+	if res.RowCount != 1 || res.Rows[0][0] != "b" {
+		t.Fatalf("unnest alias rows = %v", res.Rows)
+	}
+
+	res = execMust(t, e, `SELECT * FROM unnest(NULL::TEXT[])`)
+	if res.RowCount != 0 {
+		t.Fatalf("unnest(NULL) rows = %d, want 0", res.RowCount)
+	}
+
+	if _, err := execSQL(e, `SELECT unnest('{1,2}'::INTEGER[])`); err == nil {
+		t.Fatal("scalar unnest should be rejected")
+	}
+}
+
+func TestArrayDefaultsAndAlterTable(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, `CREATE TABLE t (id INTEGER PRIMARY KEY, tags TEXT[] DEFAULT ARRAY['a'])`)
+	execMust(t, e, `INSERT INTO t (id) VALUES (1)`)
+	res := execMust(t, e, `SELECT tags FROM t WHERE id = 1`)
+	if res.Rows[0][0] != "{a}" {
+		t.Fatalf("default tags = %v, want {a}", res.Rows[0][0])
+	}
+
+	execMust(t, e, `ALTER TABLE t ADD COLUMN nums INTEGER[] DEFAULT '{1, 2}'`)
+	res = execMust(t, e, `SELECT nums FROM t WHERE id = 1`)
+	if res.Rows[0][0] != "{1,2}" {
+		t.Fatalf("added column default = %v, want {1,2}", res.Rows[0][0])
+	}
+	execMust(t, e, `INSERT INTO t (id, nums) VALUES (2, '{3}')`)
+	res = execMust(t, e, `SELECT nums FROM t WHERE id = 2`)
+	if res.Rows[0][0] != "{3}" {
+		t.Fatalf("explicit array = %v, want {3}", res.Rows[0][0])
+	}
+}
+
+func TestArrayInCompositePrimaryKey(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, `CREATE TABLE t (id INTEGER, tags TEXT[], PRIMARY KEY (id, tags))`)
+	execMust(t, e, `INSERT INTO t VALUES (1, '{a,b}')`)
+	execMust(t, e, `INSERT INTO t VALUES (1, '{c}')`)
+	res := execMust(t, e, `SELECT count(*) FROM t`)
+	if res.Rows[0][0] != int64(2) {
+		t.Fatalf("rows = %v, want 2", res.Rows[0][0])
+	}
+	if _, err := execSQL(e, `INSERT INTO t VALUES (1, '{a, b}')`); err == nil {
+		t.Fatal("duplicate composite key with an equivalent array should conflict")
+	}
+
+	reloaded := newExec(schema, table)
+	res = execMust(t, reloaded, `SELECT tags FROM t WHERE id = 1 AND tags = '{a,b}'`)
+	if res.RowCount != 1 || res.Rows[0][0] != "{a,b}" {
+		t.Fatalf("reloaded composite-key lookup = %v", res.Rows)
+	}
+}
+
+func TestArrayRejectedShapes(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, `CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT[])`)
+
+	cases := []struct {
+		sql         string
+		unsupported bool
+	}{
+		{`INSERT INTO t VALUES (1, '{{1,2},{3,4}}')`, true},
+		{`SELECT '{{1,2},{3,4}}'::INTEGER[]`, true},
+		{`SELECT 1 = ANY('not an array')`, false},
+		{`SELECT 1 = ANY(1)`, false},
+		{`INSERT INTO t VALUES (2, '{1,2')`, false},
+		{`INSERT INTO t VALUES (3, 5)`, false},
+	}
+	for _, tc := range cases {
+		_, err := execSQL(e, tc.sql)
+		if err == nil {
+			t.Errorf("%s succeeded, want an error", tc.sql)
+			continue
+		}
+		if tc.unsupported && !errors.Is(err, pgerror.ErrFeatureNotSupported) {
+			t.Errorf("%s error = %v, want ErrFeatureNotSupported", tc.sql, err)
+		}
+		if !tc.unsupported && !errors.Is(err, pgerror.ErrInvalidText) {
+			t.Errorf("%s error = %v, want ErrInvalidText", tc.sql, err)
+		}
+	}
+}
+
+func TestArrayUpdateAndReturning(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, `CREATE TABLE t (id INTEGER PRIMARY KEY, tags TEXT[])`)
+	execMust(t, e, `INSERT INTO t VALUES (1, '{a}')`)
+	res := execMust(t, e, `UPDATE t SET tags = '{b, c}' WHERE id = 1 RETURNING tags`)
+	if res.Rows[0][0] != "{b,c}" {
+		t.Fatalf("UPDATE RETURNING tags = %v, want {b,c}", res.Rows[0][0])
+	}
+	res = execMust(t, e, `SELECT tags FROM t WHERE id = 1`)
+	if res.Rows[0][0] != "{b,c}" {
+		t.Fatalf("stored tags = %v, want {b,c}", res.Rows[0][0])
+	}
+}
+
+// The array value is a plain string in the row codec, so a second decoder sees
+// exactly the bytes the first one wrote. A freshly constructed schema and table
+// manager pair over the same store stands in for a process restart: no
+// converter runs, and the durable catalog plus row bytes are read as written.
+func TestArrayStorageReloadIsStable(t *testing.T) {
+	pool, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, `CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT[], n INTEGER[])`)
+	execMust(t, e, `INSERT INTO t VALUES (1, ARRAY['x','y'], '{1,2}')`)
+
+	session := storage.NewSession(schema, table)
+	row, err := session.GetByPK("t", "1")
+	if err != nil {
+		t.Fatalf("GetByPK: %v", err)
+	}
+	if row["v"] != "{x,y}" {
+		t.Fatalf("stored value = %#v, want {x,y}", row["v"])
+	}
+
+	reloadedSchema := storage.NewSchemaManager(pool, "testdb")
+	reloadedTable := storage.NewTableManager(pool, reloadedSchema, "testdb")
+	reloaded := newExec(reloadedSchema, reloadedTable)
+	res := execMust(t, reloaded, `SELECT v, n FROM t WHERE id = 1`)
+	if res.Rows[0][0] != "{x,y}" || res.Rows[0][1] != "{1,2}" {
+		t.Fatalf("reloaded row = %v, want [{x,y} {1,2}]", res.Rows[0])
+	}
+	if res.ColumnTypes[0] != "TEXT[]" || res.ColumnTypes[1] != "INTEGER[]" {
+		t.Fatalf("reloaded types = %v, want [TEXT[] INTEGER[]]", res.ColumnTypes)
+	}
+	// A second write through the reloaded session must encode identically.
+	execMust(t, reloaded, `INSERT INTO t (id, v) VALUES (2, '{x, y}')`)
+	res = execMust(t, reloaded, `SELECT v = '{x,y}' FROM t WHERE id = 2`)
+	if res.Rows[0][0] != true {
+		t.Fatalf("canonical reloaded write did not compare equal: %v", res.Rows[0][0])
+	}
+}
+
+// A boolean element must be a Go bool in every value context. Left as the
+// canonical "t"/"f" text, toBool("f") is true and an equality against TRUE is
+// false, so WHERE/NOT/=/ANY are all checked.
+func TestBooleanSubscriptPredicates(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, `CREATE TABLE t (id INTEGER PRIMARY KEY, flags BOOLEAN[], nums INTEGER[], ratios DOUBLE PRECISION[], amounts NUMERIC[])`)
+	execMust(t, e, `INSERT INTO t VALUES
+		(1, '{t,f}', '{10,20}', '{1.5,2.5}', '{12345678901234567890,1.10}'),
+		(2, '{f,t}', '{30,40}', '{3.5}', '{2.50}'),
+		(3, NULL, NULL, NULL, NULL)`)
+
+	cases := []struct {
+		sql  string
+		want interface{}
+	}{
+		{`SELECT count(*) FROM t WHERE flags[1]`, int64(1)},
+		{`SELECT count(*) FROM t WHERE NOT flags[1]`, int64(1)},
+		{`SELECT count(*) FROM t WHERE flags[1] = TRUE`, int64(1)},
+		{`SELECT count(*) FROM t WHERE flags[1] = FALSE`, int64(1)},
+		{`SELECT count(*) FROM t WHERE flags[1] = ANY('{true}')`, int64(1)},
+		{`SELECT count(*) FROM t WHERE flags[1] = ANY('{false}')`, int64(1)},
+		{`SELECT flags[1] = TRUE FROM t WHERE id = 2`, false},
+		{`SELECT flags[1] FROM t WHERE id = 2`, false},
+		// Integer and real elements keep their Go carrier, so the HTTP layer
+		// emits a JSON number and the wire metadata matches.
+		{`SELECT nums[2] FROM t WHERE id = 1`, int64(20)},
+		{`SELECT ratios[1] FROM t WHERE id = 1`, 1.5},
+		// NUMERIC keeps its exact text instead of a float round-trip.
+		{`SELECT amounts[1] FROM t WHERE id = 1`, "12345678901234567890"},
+		{`SELECT amounts[2] FROM t WHERE id = 1`, "1.10"},
+		{`SELECT count(*) FROM t WHERE amounts[1] = '12345678901234567890'`, int64(1)},
+		{`SELECT count(*) FROM t WHERE amounts[2] > 1`, int64(1)},
+	}
+	for _, tc := range cases {
+		res := execMust(t, e, tc.sql)
+		if got := res.Rows[0][0]; got != tc.want {
+			t.Errorf("%s = %#v, want %#v", tc.sql, got, tc.want)
+		}
+	}
+
+	res := execMust(t, e, `SELECT nums[1] AS n, ratios[1] AS r, flags[1] AS f, amounts[1] AS a FROM t WHERE id = 1`)
+	wantTypes := []string{"INTEGER", "DOUBLE", "BOOLEAN", "NUMERIC"}
+	for i, want := range wantTypes {
+		if res.ColumnTypes[i] != want {
+			t.Errorf("column %d type = %q, want %q", i, res.ColumnTypes[i], want)
+		}
+	}
+}
+
+// A text literal is canonicalized against the array-typed side, so equivalent
+// spellings compare equal to the stored canonical text.
+func TestArrayLiteralComparisonCanonicalization(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, `CREATE TABLE t (id INTEGER PRIMARY KEY, tags TEXT[], nums INTEGER[], amounts NUMERIC[])`)
+	execMust(t, e, `INSERT INTO t VALUES (1, '{a,b}', '{3,4}', '{1.10,2}')`)
+
+	cases := []struct {
+		sql  string
+		want interface{}
+	}{
+		{`SELECT count(*) FROM t WHERE tags = '{a, b}'`, int64(1)},
+		{`SELECT count(*) FROM t WHERE tags = '{"a","b"}'`, int64(1)},
+		{`SELECT count(*) FROM t WHERE '{a, b}' = tags`, int64(1)},
+		{`SELECT count(*) FROM t WHERE tags <> '{a, c}'`, int64(1)},
+		{`SELECT count(*) FROM t WHERE tags IN ('{a, b}', '{x}')`, int64(1)},
+		{`SELECT count(*) FROM t WHERE tags IS NOT DISTINCT FROM '{a, b}'`, int64(1)},
+		{`SELECT count(*) FROM t WHERE nums = '{3, 4}'`, int64(1)},
+		{`SELECT count(*) FROM t WHERE nums <> '{3,5}'`, int64(1)},
+		{`SELECT count(*) FROM t WHERE amounts = '{1.10,2}'`, int64(1)},
+		{`SELECT tags = '{a, b}' FROM t`, true},
+		{`SELECT '{a, b}' = tags FROM t`, true},
+	}
+	for _, tc := range cases {
+		res := execMust(t, e, tc.sql)
+		if got := res.Rows[0][0]; got != tc.want {
+			t.Errorf("%s = %#v, want %#v", tc.sql, got, tc.want)
+		}
+	}
+
+	// A text value that is not array text is an invalid array literal, not a
+	// silently false comparison.
+	if _, err := execSQL(e, `SELECT count(*) FROM t WHERE tags = 'not-an-array'`); !errors.Is(err, pgerror.ErrInvalidText) {
+		t.Fatalf("malformed comparison literal error = %v, want ErrInvalidText", err)
+	}
+}
+
+// NUMERIC array elements are validated but never rewritten through float64, so
+// big values and trailing zeros survive storage, reload, and casts.
+func TestNumericArrayPrecision(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, `CREATE TABLE t (id INTEGER PRIMARY KEY, amounts NUMERIC[])`)
+	execMust(t, e, `INSERT INTO t VALUES (1, '{12345678901234567890,1.10,1e+3}')`)
+
+	res := execMust(t, e, `SELECT amounts FROM t WHERE id = 1`)
+	if got := res.Rows[0][0]; got != "{12345678901234567890,1.10,1e+3}" {
+		t.Fatalf("numeric array = %v, want exact text", got)
+	}
+	if res.ColumnTypes[0] != "NUMERIC[]" {
+		t.Fatalf("numeric array type = %q, want NUMERIC[]", res.ColumnTypes[0])
+	}
+
+	reloaded := newExec(schema, table)
+	res = execMust(t, reloaded, `SELECT amounts[1], amounts[2] FROM t WHERE id = 1`)
+	if res.Rows[0][0] != "12345678901234567890" || res.Rows[0][1] != "1.10" {
+		t.Fatalf("reloaded numeric elements = %v", res.Rows[0])
+	}
+
+	res = execMust(t, e, `SELECT '{1.10}'::NUMERIC[] AS v`)
+	if res.Rows[0][0] != "{1.10}" {
+		t.Fatalf("numeric cast = %v, want {1.10}", res.Rows[0][0])
+	}
+	if res.ColumnTypes[0] != "NUMERIC[]" {
+		t.Fatalf("numeric cast type = %q, want NUMERIC[]", res.ColumnTypes[0])
+	}
+
+	// Whole-array equality is numeric-aware for NUMERIC[]: spelling differences
+	// (1.10 vs 1.1, 1e+3 vs 1000) compare equal, while a changed digit does not.
+	cases := []struct {
+		sql  string
+		want interface{}
+	}{
+		{`SELECT count(*) FROM t WHERE amounts = '{12345678901234567890,1.10,1e+3}'`, int64(1)},
+		{`SELECT count(*) FROM t WHERE amounts = '{12345678901234567890,1.1,1000}'`, int64(1)},
+		{`SELECT count(*) FROM t WHERE amounts = '{12345678901234567891,1.10,1e+3}'`, int64(0)},
+		{`SELECT count(*) FROM t WHERE amounts <> '{12345678901234567890,1.1,1000}'`, int64(0)},
+		{`SELECT amounts = '{12345678901234567890,1.1,1000}' FROM t`, true},
+		{`SELECT amounts IS DISTINCT FROM '{12345678901234567890,1.1,1000}' FROM t`, false},
+		{`SELECT '{1.10}'::NUMERIC[] = '{1.1}'::NUMERIC[]`, true},
+		{`SELECT '{1.10}'::NUMERIC[] <> '{1.1}'::NUMERIC[]`, false},
+		{`SELECT '{1.10,2.50}'::NUMERIC[] = '{1.1,2.5}'::NUMERIC[]`, true},
+		// Element comparison is exact too, so a float64 rounding cannot make two
+		// adjacent 20-digit values equal.
+		{`SELECT count(*) FROM t WHERE amounts[1] = '12345678901234567890'`, int64(1)},
+		{`SELECT count(*) FROM t WHERE amounts[1] = '12345678901234567891'`, int64(0)},
+		{`SELECT count(*) FROM t WHERE amounts[2] = '1.1'`, int64(1)},
+		{`SELECT count(*) FROM t WHERE amounts[2] = '1.10'`, int64(1)},
+		{`SELECT count(*) FROM t WHERE amounts[2] = ANY('{1.1}')`, int64(1)},
+		{`SELECT count(*) FROM t WHERE amounts[2] = ANY('{1.11}')`, int64(0)},
+		{`SELECT count(*) FROM t WHERE amounts IN ('{12345678901234567890,1.1,1000}')`, int64(1)},
+	}
+	for _, tc := range cases {
+		res := execMust(t, e, tc.sql)
+		if got := res.Rows[0][0]; got != tc.want {
+			t.Errorf("%s = %#v, want %#v", tc.sql, got, tc.want)
+		}
+	}
+}
+
+// NUMERIC element text is trimmed and a leading + is normalized; non-decimal
+// spellings ParseFloat would accept (hex, NaN, Inf) are rejected.
+func TestNumericArrayValidation(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+
+	for _, tc := range []struct{ sql, want string }{
+		{`SELECT '{" 1.10 "}'::NUMERIC[]`, "{1.10}"},
+		{`SELECT '{+1.10}'::NUMERIC[]`, "{1.10}"},
+		{`SELECT '{-1.10,+.5}'::NUMERIC[]`, "{-1.10,.5}"},
+		{`SELECT '{1e+3,2E-2}'::NUMERIC[]`, "{1e+3,2E-2}"},
+	} {
+		res := execMust(t, e, tc.sql)
+		if got := res.Rows[0][0]; got != tc.want {
+			t.Errorf("%s = %v, want %s", tc.sql, got, tc.want)
+		}
+	}
+
+	for _, sql := range []string{
+		`SELECT '{0x1p4}'::NUMERIC[]`,
+		`SELECT '{NaN}'::NUMERIC[]`,
+		`SELECT '{Inf}'::NUMERIC[]`,
+		`SELECT '{Infinity}'::NUMERIC[]`,
+		`SELECT '{1.2.3}'::NUMERIC[]`,
+		`SELECT '{1e}'::NUMERIC[]`,
+		`SELECT '{+}'::NUMERIC[]`,
+	} {
+		if _, err := execSQL(e, sql); !errors.Is(err, pgerror.ErrInvalidText) {
+			t.Errorf("%s error = %v, want ErrInvalidText", sql, err)
+		}
+	}
+}
+
+func TestArrayIndexTypeValidation(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, `CREATE TABLE t (id INTEGER PRIMARY KEY, nums INTEGER[])`)
+	execMust(t, e, `INSERT INTO t VALUES (1, '{10,20}')`)
+
+	for _, sql := range []string{
+		`SELECT nums[1.5] FROM t`,
+		`SELECT nums[TRUE] FROM t`,
+		`SELECT nums['x'] FROM t`,
+		`SELECT array_length(nums, 1.5) FROM t`,
+		`SELECT array_length(nums, TRUE) FROM t`,
+		`SELECT array_length(nums, 'x') FROM t`,
+	} {
+		_, err := execSQL(e, sql)
+		var analysisErr *analyzer.AnalysisError
+		if !errors.Is(err, pgerror.ErrDatatypeMismatch) && !(errors.As(err, &analysisErr) && analysisErr.Type == analyzer.ErrTypeMismatch) {
+			t.Errorf("%s error = %v, want a datatype mismatch", sql, err)
+		}
+	}
+
+	// NULL subscripts and dimensions still yield NULL.
+	for _, sql := range []string{
+		`SELECT nums[NULL] FROM t`,
+		`SELECT array_length(nums, NULL) FROM t`,
+	} {
+		res := execMust(t, e, sql)
+		if res.Rows[0][0] != nil {
+			t.Errorf("%s = %#v, want nil", sql, res.Rows[0][0])
+		}
+	}
+}
+
+func TestMalformedStoredBooleanArrayPropagates(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, `CREATE TABLE bad (id INTEGER PRIMARY KEY, b BOOLEAN[])`)
+	// Write around the executor's normalization to model a corrupted or
+	// hand-authored catalog value.
+	session := storage.NewSession(schema, table)
+	if _, err := session.InsertWithRowID("bad", storage.Row{"id": int64(1), "b": "{t,falsy}"}); err != nil {
+		t.Fatalf("seed malformed row: %v", err)
+	}
+
+	if _, err := execSQL(e, `SELECT b[2] FROM bad`); !errors.Is(err, pgerror.ErrInvalidText) {
+		t.Fatalf("subscript error = %v, want ErrInvalidText", err)
+	}
+	// unnest() decodes through the same helper, so it must not coerce the
+	// element to false.
+	element := "falsy"
+	if value, err := arrayElementValue(&element, "BOOLEAN"); !errors.Is(err, pgerror.ErrInvalidText) || value != nil {
+		t.Fatalf("arrayElementValue = %#v, %v; want ErrInvalidText", value, err)
+	}
+}
+
+func TestArrayEmptyElementRejected(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	for _, sql := range []string{
+		`SELECT '{,a}'::TEXT[]`,
+		`SELECT '{a,,b}'::TEXT[]`,
+		`SELECT '{a,}'::TEXT[]`,
+	} {
+		if _, err := execSQL(e, sql); !errors.Is(err, pgerror.ErrInvalidText) {
+			t.Errorf("%s error = %v, want ErrInvalidText", sql, err)
+		}
+	}
+	// A quoted empty element is a valid empty string.
+	res := execMust(t, e, `SELECT '{""}'::TEXT[]`)
+	if res.Rows[0][0] != `{""}` {
+		t.Fatalf("quoted empty element = %v", res.Rows[0][0])
+	}
+}
+
+func TestMultidimensionalConstructorRejected(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	for _, sql := range []string{
+		`SELECT ARRAY[[1,2],[3,4]]`,
+		`SELECT ARRAY[[1,2]]`,
+	} {
+		if _, err := execSQL(e, sql); !errors.Is(err, pgerror.ErrFeatureNotSupported) {
+			t.Errorf("%s error = %v, want ErrFeatureNotSupported", sql, err)
+		}
+	}
+}
+
+// The parsed-array cache is keyed by AST node and checked against the evaluated
+// text, so a row-dependent array is re-parsed when its value changes.
+func TestArrayParseCacheTracksRowValues(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, `CREATE TABLE t (id INTEGER PRIMARY KEY, nums INTEGER[])`)
+	execMust(t, e, `INSERT INTO t VALUES (1, '{1}'), (2, '{2}')`)
+
+	res := execMust(t, e, `SELECT count(*) FROM t WHERE 1 = ANY(nums)`)
+	if res.Rows[0][0] != int64(1) {
+		t.Fatalf("1 = ANY(nums) count = %v, want 1", res.Rows[0][0])
+	}
+	res = execMust(t, e, `SELECT count(*) FROM t WHERE 2 = ANY(nums)`)
+	if res.Rows[0][0] != int64(1) {
+		t.Fatalf("2 = ANY(nums) count = %v, want 1", res.Rows[0][0])
+	}
+}
+
+// The lexer reads a bracket after an expression as a subscript, so the SQLite
+// bracket-alias shapes fail instead of silently aliasing. This pins the chosen
+// reading at execution time.
+func TestBracketAliasShapesFailAtExecution(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, `CREATE TABLE t (id INTEGER PRIMARY KEY)`)
+
+	if _, err := execSQL(e, `SELECT count(*) [cnt] FROM t`); err == nil {
+		t.Fatal("count(*) [cnt] was accepted as an alias")
+	}
+	if _, err := execSQL(e, `SELECT 1 [x]`); err == nil {
+		t.Fatal("1 [x] was accepted as an alias")
+	}
+	if _, err := execSQL(e, `SELECT * FROM t [a]`); err == nil {
+		t.Fatal("FROM t [a] was accepted as an alias")
+	}
+}
+
+// The array cache is keyed by AST node and validated by text: a repeated
+// evaluation with the same value reuses the parsed slice, while a changed value
+// is parsed again.
+func TestParsedArrayMemoizesPerExpression(t *testing.T) {
+	e := &Executor{arrayCache: make(map[parser.Expr]*arrayParseEntry)}
+	expr := &parser.LiteralExpr{Type: lexer.TokenString, Value: "{1,2}"}
+
+	first, err := e.parsedArray(expr, "{1,2}")
+	if err != nil || len(first) != 2 {
+		t.Fatalf("first parse = %v, %v", first, err)
+	}
+	second, err := e.parsedArray(expr, "{1,2}")
+	if err != nil || len(second) != 2 {
+		t.Fatalf("second parse = %v, %v", second, err)
+	}
+	if &first[0] != &second[0] {
+		t.Fatal("identical array text was parsed twice")
+	}
+
+	third, err := e.parsedArray(expr, "{3}")
+	if err != nil || len(third) != 1 || *third[0] != "3" {
+		t.Fatalf("changed value = %v, %v", third, err)
+	}
+
+	// A nil cache (evaluation outside Execute) still parses.
+	bare := &Executor{}
+	if values, err := bare.parsedArray(expr, "{4}"); err != nil || len(values) != 1 {
+		t.Fatalf("uncached parse = %v, %v", values, err)
+	}
+}
+
+// A constant array operand is evaluated once per statement; a column-dependent
+// operand is evaluated for every row so changed values are never reused.
+func TestArrayOperandMemoizesConstantExpressions(t *testing.T) {
+	e := &Executor{arrayValueCache: make(map[parser.Expr]interface{})}
+	constant := &parser.CastExpr{
+		Expr: &parser.LiteralExpr{Type: lexer.TokenString, Value: "{1,2}"},
+		Type: parser.DataType{Name: "INTEGER", Array: true},
+	}
+	calls := 0
+	evaluate := func() (interface{}, error) {
+		calls++
+		return "{1,2}", nil
+	}
+	for i := 0; i < 5; i++ {
+		if _, err := e.arrayOperand(constant, evaluate); err != nil {
+			t.Fatal(err)
+		}
+	}
+	if calls != 1 {
+		t.Fatalf("constant operand evaluated %d times, want 1", calls)
+	}
+
+	calls = 0
+	column := &parser.ColumnRef{Column: "nums"}
+	for i := 0; i < 3; i++ {
+		if _, err := e.arrayOperand(column, evaluate); err != nil {
+			t.Fatal(err)
+		}
+	}
+	if calls != 3 {
+		t.Fatalf("column operand evaluated %d times, want 3", calls)
+	}
+}
+
+// Column typing survives a derived table: the outer statement must decode
+// boolean subscripts instead of treating "t"/"f" as text.
+func TestDerivedTableArrayTyping(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, `CREATE TABLE t (id INTEGER PRIMARY KEY, flags BOOLEAN[], tags TEXT[])`)
+	execMust(t, e, `INSERT INTO t VALUES (1, '{t,f}', '{a,b}'), (2, '{f,t}', '{c}')`)
+
+	cases := []struct {
+		sql  string
+		want interface{}
+	}{
+		{`SELECT count(*) FROM (SELECT flags FROM t) x WHERE flags[1]`, int64(1)},
+		{`SELECT count(*) FROM (SELECT flags FROM t) x WHERE NOT flags[1]`, int64(1)},
+		{`SELECT count(*) FROM (SELECT flags FROM t) x WHERE flags[1] = TRUE`, int64(1)},
+		{`SELECT count(*) FROM (SELECT flags FROM t) x WHERE flags[1] = ANY('{true}')`, int64(1)},
+		{`SELECT count(*) FROM (SELECT tags FROM t) x WHERE tags = '{a, b}'`, int64(1)},
+		{`SELECT count(*) FROM (SELECT tags FROM t) x WHERE tags = '{"a","b"}'`, int64(1)},
+	}
+	for _, tc := range cases {
+		res := execMust(t, e, tc.sql)
+		if got := res.Rows[0][0]; got != tc.want {
+			t.Errorf("%s = %#v, want %#v", tc.sql, got, tc.want)
+		}
+	}
+}
+
+// Column typing survives a CTE, including NOT over a boolean element.
+func TestCTEArrayTyping(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, `CREATE TABLE t (id INTEGER PRIMARY KEY, flags BOOLEAN[], nums INTEGER[])`)
+	execMust(t, e, `INSERT INTO t VALUES (1, '{t,f}', '{10,20}'), (2, '{f,t}', '{30,40}')`)
+
+	cases := []struct {
+		sql  string
+		want interface{}
+	}{
+		{`WITH c AS (SELECT flags FROM t) SELECT count(*) FROM c WHERE flags[1]`, int64(1)},
+		{`WITH c AS (SELECT flags FROM t) SELECT count(*) FROM c WHERE NOT flags[1]`, int64(1)},
+		{`WITH c AS (SELECT flags FROM t) SELECT count(*) FROM c WHERE flags[1] = ANY('{true}')`, int64(1)},
+		{`WITH c AS (SELECT nums FROM t) SELECT count(*) FROM c WHERE nums[1] = 10`, int64(1)},
+		{`WITH c AS (SELECT nums FROM t) SELECT count(*) FROM c WHERE nums = '{10,20}'`, int64(1)},
+		{`WITH c AS (SELECT nums FROM t) SELECT count(*) FROM c WHERE nums = '{10, 20}'`, int64(1)},
+	}
+	for _, tc := range cases {
+		res := execMust(t, e, tc.sql)
+		if got := res.Rows[0][0]; got != tc.want {
+			t.Errorf("%s = %#v, want %#v", tc.sql, got, tc.want)
+		}
+	}
+}
+
+// A materialized CTE shadows a real table with the same name. Its own column
+// types must win; reading the shadowed table's schema would decode INTEGER[]
+// elements as text or report a spurious invalid-array error.
+func TestCTEShadowingRealTableUsesCTETypes(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, `CREATE TABLE shadow (tags INTEGER[], flags TEXT[])`)
+	execMust(t, e, `INSERT INTO shadow VALUES ('{1}', '{"not a bool"}')`)
+	execMust(t, e, `CREATE TABLE docs (id INTEGER PRIMARY KEY, tags TEXT[], flags BOOLEAN[])`)
+	execMust(t, e, `INSERT INTO docs VALUES (1, '{a,b}', '{f}')`)
+
+	// Boolean NOT only flips when the CTE's BOOLEAN[] type is used.
+	res := execMust(t, e, `WITH shadow AS (SELECT flags FROM docs) SELECT count(*) FROM shadow WHERE NOT flags[1]`)
+	if res.Rows[0][0] != int64(1) {
+		t.Fatalf("CTE boolean shadow count = %v, want 1", res.Rows[0][0])
+	}
+	// TEXT[] element access accepts 'a'; the shadowed INTEGER[] type would fail.
+	res = execMust(t, e, `WITH shadow AS (SELECT tags FROM docs) SELECT count(*) FROM shadow WHERE tags[1] = 'a'`)
+	if res.Rows[0][0] != int64(1) {
+		t.Fatalf("CTE text shadow count = %v, want 1", res.Rows[0][0])
+	}
+	// Comparison canonicalization also uses the CTE's type.
+	res = execMust(t, e, `WITH shadow AS (SELECT tags FROM docs) SELECT count(*) FROM shadow WHERE tags = '{a, b}'`)
+	if res.Rows[0][0] != int64(1) {
+		t.Fatalf("CTE comparison shadow count = %v, want 1", res.Rows[0][0])
+	}
+}
+
+// The deadlock fix must hold for a locked in-transaction scan that evaluates an
+// array subscript: a SELECT over the table followed by an UPDATE must not
+// re-enter the session lock.
+func TestArraySubscriptInTransactionScan(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, `CREATE TABLE t (id INTEGER PRIMARY KEY, flags BOOLEAN[], n INTEGER)`)
+	execMust(t, e, `INSERT INTO t VALUES (1, '{t,f}', 0), (2, '{f,t}', 0)`)
+
+	execMust(t, e, `BEGIN`)
+	runWithTimeout(t, 5*time.Second, func() {
+		res := execMust(t, e, `SELECT count(*) FROM t WHERE flags[1]`)
+		if res.Rows[0][0] != int64(1) {
+			t.Errorf("in-transaction count = %v, want 1", res.Rows[0][0])
+		}
+	})
+	runWithTimeout(t, 5*time.Second, func() {
+		execMust(t, e, `UPDATE t SET n = n + 1 WHERE flags[1]`)
+	})
+	execMust(t, e, `COMMIT`)
+
+	res := execMust(t, e, `SELECT n FROM t ORDER BY id`)
+	if res.Rows[0][0] != int64(1) || res.Rows[1][0] != int64(0) {
+		t.Fatalf("post-commit values = %v", res.Rows)
+	}
+}
+
+// canonicalArrayValue memoizes a constant comparison literal per statement and
+// still re-canonicalizes changed text or a different target type.
+func TestCanonicalArrayValueMemoizes(t *testing.T) {
+	e := &Executor{arrayCanonicalCache: make(map[parser.Expr]*arrayCanonicalEntry)}
+	e.pushTypeScope(func(_, column string) (string, bool) {
+		if strings.EqualFold(column, "tags") {
+			return "TEXT[]", true
+		}
+		return "", false
+	})
+	expr := &parser.ColumnRef{Column: "tags"}
+
+	first, err := e.canonicalArrayValue(expr, "{a, b}")
+	if err != nil || first != "{a,b}" {
+		t.Fatalf("canonical = %v, %v", first, err)
+	}
+	if _, ok := e.arrayCanonicalCache[expr]; !ok {
+		t.Fatal("canonical literal was not cached")
+	}
+	second, err := e.canonicalArrayValue(expr, "{a, b}")
+	if err != nil || second != "{a,b}" {
+		t.Fatalf("cached canonical = %v, %v", second, err)
+	}
+	changed, err := e.canonicalArrayValue(expr, "{c}")
+	if err != nil || changed != "{c}" {
+		t.Fatalf("changed canonical = %v, %v", changed, err)
+	}
+
+	if _, err := e.canonicalArrayValue(expr, "not-an-array"); !errors.Is(err, pgerror.ErrInvalidText) {
+		t.Fatalf("malformed literal error = %v, want ErrInvalidText", err)
+	}
+	if _, err := e.canonicalArrayValue(expr, "not-an-array"); !errors.Is(err, pgerror.ErrInvalidText) {
+		t.Fatalf("cached malformed literal error = %v, want ErrInvalidText", err)
+	}
+}
+
+// A derived table or CTE materialized on the right side of a JOIN also keeps
+// its column types for the outer WHERE.
+func TestJoinedDerivedAndCTEArrayTyping(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, `CREATE TABLE t (id INTEGER PRIMARY KEY, flags BOOLEAN[])`)
+	execMust(t, e, `INSERT INTO t VALUES (1, '{t,f}'), (2, '{f,t}')`)
+	execMust(t, e, `CREATE TABLE u (id INTEGER PRIMARY KEY, flags BOOLEAN[], tags TEXT[])`)
+	execMust(t, e, `INSERT INTO u VALUES (1, '{f,t}', '{a,b}'), (2, '{t,f}', '{c}')`)
+
+	cases := []struct {
+		sql  string
+		want interface{}
+	}{
+		{`SELECT count(*) FROM t JOIN (SELECT id, flags FROM u) x ON x.id = t.id WHERE NOT x.flags[1]`, int64(1)},
+		{`SELECT count(*) FROM t JOIN (SELECT id, flags FROM u) x ON x.id = t.id WHERE x.flags[1] = ANY('{true}')`, int64(1)},
+		{`SELECT count(*) FROM t JOIN (SELECT id, tags FROM u) x ON x.id = t.id WHERE x.tags = '{a, b}'`, int64(1)},
+		{`WITH x AS (SELECT id, flags FROM u) SELECT count(*) FROM t JOIN x ON x.id = t.id WHERE NOT x.flags[1]`, int64(1)},
+		{`WITH x AS (SELECT id, tags FROM u) SELECT count(*) FROM t JOIN x ON x.id = t.id WHERE x.tags = '{a, b}'`, int64(1)},
+	}
+	for _, tc := range cases {
+		res := execMust(t, e, tc.sql)
+		if got := res.Rows[0][0]; got != tc.want {
+			t.Errorf("%s = %#v, want %#v", tc.sql, got, tc.want)
+		}
+	}
+}
+
+// A view expands to a derived table, so it keeps the same typing guarantees.
+func TestViewArrayTyping(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, `CREATE TABLE t (id INTEGER PRIMARY KEY, flags BOOLEAN[], tags TEXT[])`)
+	execMust(t, e, `INSERT INTO t VALUES (1, '{t,f}', '{a,b}'), (2, '{f,t}', '{c}')`)
+	execMust(t, e, `CREATE VIEW v AS SELECT flags, tags FROM t`)
+
+	for _, tc := range []struct {
+		sql  string
+		want interface{}
+	}{
+		{`SELECT count(*) FROM v WHERE NOT flags[1]`, int64(1)},
+		{`SELECT count(*) FROM v WHERE flags[1] = ANY('{true}')`, int64(1)},
+		{`SELECT count(*) FROM v WHERE tags = '{a, b}'`, int64(1)},
+	} {
+		res := execMust(t, e, tc.sql)
+		if got := res.Rows[0][0]; got != tc.want {
+			t.Errorf("%s = %#v, want %#v", tc.sql, got, tc.want)
+		}
+	}
+}

+ 15 - 8
pkg/executor/cte.go

@@ -12,9 +12,12 @@ import (
 // cycle cannot spin forever.
 const maxRecursiveCTEIterations = 1000
 
-// cteTable is a materialized common table expression.
+// cteTable is a materialized common table expression. types is positionally
+// aligned with columns and carries the result types the CTE's SELECT reported,
+// so array subscripts and comparisons in the outer query keep their typing.
 type cteTable struct {
 	columns []string
+	types   []string
 	rows    []storage.Row
 }
 
@@ -83,7 +86,7 @@ func (e *Executor) materializeCTE(cte *parser.CTE) error {
 	}
 
 	for i := 0; i < maxRecursiveCTEIterations; i++ {
-		e.cteTables[strings.ToLower(cte.Name)] = &cteTable{columns: cols, rows: working}
+		e.cteTables[strings.ToLower(cte.Name)] = &cteTable{columns: cols, types: anchor.ColumnTypes, rows: working}
 		recursive, err := e.executeSelect(compound.Right)
 		if err != nil {
 			return err
@@ -106,7 +109,7 @@ func (e *Executor) materializeCTE(cte *parser.CTE) error {
 		working = fresh
 	}
 
-	e.cteTables[strings.ToLower(cte.Name)] = &cteTable{columns: cols, rows: accumulated}
+	e.cteTables[strings.ToLower(cte.Name)] = &cteTable{columns: cols, types: anchor.ColumnTypes, rows: accumulated}
 	return nil
 }
 
@@ -117,7 +120,7 @@ func (e *Executor) registerCTEResult(name string, declared []string, res *Result
 	if len(cols) == 0 {
 		cols = res.Columns
 	}
-	e.cteTables[strings.ToLower(name)] = &cteTable{columns: cols, rows: resultToCTERows(res, cols)}
+	e.cteTables[strings.ToLower(name)] = &cteTable{columns: cols, types: res.ColumnTypes, rows: resultToCTERows(res, cols)}
 }
 
 // resultToCTERows maps each result row to a storage.Row keyed by cols
@@ -156,11 +159,11 @@ func (e *Executor) cteTableExists(name string) bool {
 }
 
 // materializeJoinSubquery runs a derived table used on the right side of a JOIN
-// and returns its rows and schema.
-func (e *Executor) materializeJoinSubquery(ref *parser.TableRef) ([]storage.Row, *storage.Schema, error) {
+// and returns its rows, schema, and the result column types.
+func (e *Executor) materializeJoinSubquery(ref *parser.TableRef) ([]storage.Row, *storage.Schema, []string, []string, error) {
 	res, err := e.executeSelect(ref.Subquery)
 	if err != nil {
-		return nil, nil, err
+		return nil, nil, nil, nil, err
 	}
 	rows := make([]storage.Row, 0, len(res.Rows))
 	for _, values := range res.Rows {
@@ -172,7 +175,11 @@ func (e *Executor) materializeJoinSubquery(ref *parser.TableRef) ([]storage.Row,
 		}
 		rows = append(rows, row)
 	}
-	return rows, schemaFromColumns(res.Columns), nil
+	schema := &storage.Schema{Name: "derived", Columns: make([]storage.Column, len(res.Columns))}
+	for i, col := range res.Columns {
+		schema.Columns[i] = storage.Column{Name: col, Type: typeAt(res.ColumnTypes, i)}
+	}
+	return rows, schema, res.Columns, res.ColumnTypes, nil
 }
 
 // cloneRows returns a shallow copy of each row so callers cannot mutate a

+ 7 - 0
pkg/executor/ddl_defaults.go

@@ -54,6 +54,13 @@ func isConstantExpr(expr parser.Expr) bool {
 		return isConstantExpr(ex.Expr)
 	case *parser.CastExpr:
 		return isConstantExpr(ex.Expr)
+	case *parser.ArrayExpr:
+		for _, element := range ex.Elements {
+			if !isConstantExpr(element) {
+				return false
+			}
+		}
+		return true
 	default:
 		return false
 	}

+ 385 - 18
pkg/executor/executor.go

@@ -71,6 +71,31 @@ type Executor struct {
 	// Keyed by subquery AST pointer; valid for one top-level Execute call.
 	correlatedAggCache map[*parser.SelectStmt]*correlatedAggCache
 
+	// Per-query cache of parsed array literals, keyed by the array expression's
+	// AST pointer. The evaluated text is compared before a hit is used so a
+	// value that changes between rows or executions is re-parsed.
+	arrayCache map[parser.Expr]*arrayParseEntry
+
+	// Per-query cache of evaluated constant array operands, keyed by AST
+	// pointer. It keeps `= ANY($1)` from rebuilding the same canonical array
+	// text for every row of a scan.
+	arrayValueCache map[parser.Expr]interface{}
+
+	// Per-query cache of canonicalized array comparison literals, keyed by AST
+	// pointer and validated by the source text, so `tags = '{a, b}'` parses the
+	// literal once per statement rather than once per row.
+	arrayCanonicalCache map[parser.Expr]*arrayCanonicalEntry
+
+	// typeScopes holds the column-type resolvers of the SELECT statements being
+	// evaluated, innermost last. Value-time consumers (array subscripts) use
+	// them to recover a column's declared type.
+	typeScopes []columnTypeResolver
+
+	// joinTypeScopes holds the per-statement records of right-hand relations
+	// materialized while executing joins, so their column types are available
+	// after the join rows are built.
+	joinTypeScopes []*joinTypeScope
+
 	// In-memory view registry: view name (lowercase) → SELECT AST.
 	views map[string]*parser.SelectStmt
 
@@ -209,9 +234,15 @@ func (e *Executor) Execute(stmt parser.Statement) (*Result, error) {
 	}
 	e.subqueryCache = make(map[*parser.SelectStmt]*Result)
 	e.correlatedAggCache = make(map[*parser.SelectStmt]*correlatedAggCache)
+	e.arrayCache = make(map[parser.Expr]*arrayParseEntry)
+	e.arrayValueCache = make(map[parser.Expr]interface{})
+	e.arrayCanonicalCache = make(map[parser.Expr]*arrayCanonicalEntry)
 	defer func() {
 		e.subqueryCache = nil
 		e.correlatedAggCache = nil
+		e.arrayCache = nil
+		e.arrayValueCache = nil
+		e.arrayCanonicalCache = nil
 	}()
 
 	// PRAGMA doesn't need analysis. SQLite catalog-introspection pragmas
@@ -384,7 +415,7 @@ func isCountStarSingleTable(stmt *parser.SelectStmt) bool {
 		return false
 	}
 	ref := stmt.From[0]
-	if ref.Subquery != nil || ref.Join != nil {
+	if ref.Subquery != nil || ref.Join != nil || ref.Function != nil {
 		return false
 	}
 	if len(stmt.Columns) != 1 || stmt.Columns[0].Star {
@@ -402,6 +433,19 @@ func isCountStarSingleTable(stmt *parser.SelectStmt) bool {
 
 // executeSelect executes a SELECT statement (or compound SELECT).
 func (e *Executor) executeSelect(stmt *parser.SelectStmt) (*Result, error) {
+	// The join scope is pushed below the snapshot so a real table's unqualified
+	// column keeps precedence; it answers for right-side derived tables and
+	// CTEs that the snapshot cannot read without materializing them again.
+	joinScope := newJoinTypeScope()
+	e.joinTypeScopes = append(e.joinTypeScopes, joinScope)
+	e.pushTypeScope(joinScope.resolve)
+	e.pushTypeScope(e.snapshotTypeResolver(stmt.From))
+	defer func() {
+		e.popTypeScope()
+		e.popTypeScope()
+		e.joinTypeScopes = e.joinTypeScopes[:len(e.joinTypeScopes)-1]
+	}()
+
 	if len(stmt.With) > 0 {
 		return e.executeWith(stmt)
 	}
@@ -413,6 +457,10 @@ func (e *Executor) executeSelect(stmt *parser.SelectStmt) (*Result, error) {
 		return e.executeSelectExpr(stmt)
 	}
 
+	if stmt.From[0].Function != nil {
+		return e.executeSelectFromTableFunction(stmt)
+	}
+
 	// Non-recursive CTEs are desugared to derived tables by the parser. Preserve
 	// every entry in a comma-separated list instead of dispatching solely on the
 	// first derived table.
@@ -454,7 +502,11 @@ func (e *Executor) executeSelect(stmt *parser.SelectStmt) (*Result, error) {
 
 	// A materialized CTE is served from memory, not from storage.
 	if cte, ok := e.cteTableFor(tableName); ok {
-		return e.executeSelectOnMaterialized(stmt, cte.columns, cteRowsToValues(cte))
+		qualifier := stmt.From[0].Alias
+		if qualifier == "" {
+			qualifier = stmt.From[0].Name
+		}
+		return e.executeSelectOnMaterializedTyped(stmt, cte.columns, cte.types, cteRowsToValues(cte), qualifier)
 	}
 
 	// Transparently expand view references as derived-table subqueries.
@@ -1506,7 +1558,68 @@ func (e *Executor) executeSelectExpr(stmt *parser.SelectStmt) (*Result, error) {
 	return result, nil
 }
 
+// executeSelectFromTableFunction materializes unnest(array) as a one-column
+// 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)
+	}
+	if len(ref.Function.Args) != 1 {
+		return nil, fmt.Errorf("unnest() requires exactly one array argument")
+	}
+	value, err := e.evalExpr(ref.Function.Args[0], nil)
+	if err != nil {
+		return nil, fmt.Errorf("unnest argument: %w", err)
+	}
+	elementType := "TEXT"
+	if typeName := inferExprType(ref.Function.Args[0], nil); typeName != "" {
+		if element, ok := arrayElementType(typeName); ok {
+			elementType = element
+		}
+	}
+	rows := make([][]interface{}, 0)
+	if value != nil {
+		elements, parseErr := parseArrayLiteral(toString(value))
+		if parseErr != nil {
+			return nil, fmt.Errorf("unnest requires an array value: %w", parseErr)
+		}
+		for _, element := range elements {
+			typed, valueErr := arrayElementValue(element, elementType)
+			if valueErr != nil {
+				return nil, fmt.Errorf("unnest element: %w", valueErr)
+			}
+			rows = append(rows, []interface{}{typed})
+		}
+	}
+	column := "unnest"
+	result, err := e.executeSelectOnMaterialized(stmt, []string{column}, rows)
+	if err != nil {
+		return nil, err
+	}
+	if len(result.Columns) == len(stmt.Columns) {
+		types := make([]string, 0, len(stmt.Columns))
+		for _, projection := range stmt.Columns {
+			if projection.Star {
+				types = append(types, elementType)
+				continue
+			}
+			types = append(types, inferExprType(projection.Expr, func(_, columnName string) (string, bool) {
+				if strings.EqualFold(columnName, column) {
+					return elementType, true
+				}
+				return "", false
+			}))
+		}
+		result.ColumnTypes = types
+	}
+	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.
 func (e *Executor) executeSelectFromSubquery(stmt *parser.SelectStmt) (*Result, error) {
 	// Execute the subquery to get the derived table
 	subqueryResult, err := e.executeSelect(stmt.From[0].Subquery)
@@ -1514,20 +1627,26 @@ func (e *Executor) executeSelectFromSubquery(stmt *parser.SelectStmt) (*Result,
 		return nil, fmt.Errorf("subquery error: %w", err)
 	}
 
-	return e.executeSelectOnMaterialized(stmt, subqueryResult.Columns, subqueryResult.Rows)
+	qualifier := stmt.From[0].Alias
+	if qualifier == "" {
+		qualifier = stmt.From[0].Name
+	}
+	return e.executeSelectOnMaterializedTyped(stmt, subqueryResult.Columns, subqueryResult.ColumnTypes, subqueryResult.Rows, qualifier)
 }
 
 func (e *Executor) executeSelectFromSubqueries(stmt *parser.SelectStmt) (*Result, error) {
 	rows := []storage.Row{{}}
-	var columns, starKeys []string
+	var columns, starKeys, types, qualifiers []string
 	for _, ref := range stmt.From {
 		res, err := e.executeSelect(ref.Subquery)
 		if err != nil {
 			return nil, fmt.Errorf("subquery error: %w", err)
 		}
 		columns = append(columns, res.Columns...)
-		for _, column := range res.Columns {
+		for i, column := range res.Columns {
 			starKeys = append(starKeys, ref.Alias+"."+column)
+			qualifiers = append(qualifiers, ref.Alias)
+			types = append(types, typeAt(res.ColumnTypes, i))
 		}
 
 		right := make([]storage.Row, 0, len(res.Rows))
@@ -1556,13 +1675,38 @@ func (e *Executor) executeSelectFromSubqueries(stmt *parser.SelectStmt) (*Result
 		}
 		rows = joined
 	}
+	e.pushTypeScope(materializedTypeResolver(columns, types, qualifiers))
+	defer e.popTypeScope()
 	return e.executeSelectOnMaterializedRows(stmt, columns, starKeys, rows)
 }
 
+// typeAt returns the type at index i, or the empty string when the position has
+// no recorded type (which the resolver reports as known-but-untyped).
+func typeAt(types []string, i int) string {
+	if i < len(types) {
+		return types[i]
+	}
+	return ""
+}
+
 // executeSelectOnMaterialized runs a SELECT whose FROM[0] is already
-// materialized as (columns, rowValues). It is shared by derived tables and
-// materialized CTEs.
+// materialized as (columns, rowValues) without column-type metadata.
 func (e *Executor) executeSelectOnMaterialized(stmt *parser.SelectStmt, columns []string, rowValues [][]interface{}) (*Result, error) {
+	return e.executeSelectOnMaterializedTyped(stmt, columns, nil, rowValues, "")
+}
+
+// executeSelectOnMaterializedTyped runs the same materialized SELECT with the
+// relation's column types exposed to value-time consumers such as array
+// subscripts. qualifier names the relation (alias or name) for qualified column
+// references; one qualifier applies to every column.
+func (e *Executor) executeSelectOnMaterializedTyped(stmt *parser.SelectStmt, columns, columnTypes []string, rowValues [][]interface{}, qualifier string) (*Result, error) {
+	qualifiers := make([]string, len(columns))
+	for i := range qualifiers {
+		qualifiers[i] = qualifier
+	}
+	e.pushTypeScope(materializedTypeResolver(columns, columnTypes, qualifiers))
+	defer e.popTypeScope()
+
 	derivedRows := make([]storage.Row, 0, len(rowValues))
 	for _, values := range rowValues {
 		row := make(storage.Row, len(columns))
@@ -1581,7 +1725,7 @@ func (e *Executor) executeSelectOnMaterialized(stmt *parser.SelectStmt, columns
 // every SELECT * column, including duplicate column names.
 func (e *Executor) executeSelectOnMaterializedCTEs(stmt *parser.SelectStmt) (*Result, error) {
 	rows := []storage.Row{{}}
-	var columns, starKeys []string
+	var columns, starKeys, types, qualifiers []string
 	for _, ref := range stmt.From {
 		cte, _ := e.cteTableFor(ref.Name)
 		qualifier := ref.Alias
@@ -1589,8 +1733,10 @@ func (e *Executor) executeSelectOnMaterializedCTEs(stmt *parser.SelectStmt) (*Re
 			qualifier = ref.Name
 		}
 		columns = append(columns, cte.columns...)
-		for _, column := range cte.columns {
+		for i, column := range cte.columns {
 			starKeys = append(starKeys, qualifier+"."+column)
+			qualifiers = append(qualifiers, qualifier)
+			types = append(types, typeAt(cte.types, i))
 		}
 
 		right := cloneRows(cte.rows)
@@ -1612,6 +1758,8 @@ func (e *Executor) executeSelectOnMaterializedCTEs(stmt *parser.SelectStmt) (*Re
 		}
 		rows = joined
 	}
+	e.pushTypeScope(materializedTypeResolver(columns, types, qualifiers))
+	defer e.popTypeScope()
 	return e.executeSelectOnMaterializedRows(stmt, columns, starKeys, rows)
 }
 
@@ -2115,14 +2263,17 @@ func (e *Executor) executeJoinsWithMode(tableRef parser.TableRef, leftRows []sto
 	var err error
 	switch {
 	case rightTableRef.Subquery != nil:
-		rightRows, rightSchema, err = e.materializeJoinSubquery(rightTableRef)
+		var columns, types []string
+		rightRows, rightSchema, columns, types, err = e.materializeJoinSubquery(rightTableRef)
 		if err != nil {
 			return nil, err
 		}
+		e.recordJoinColumns(rightAlias, columns, types)
 	case e.cteTableExists(rightTable):
 		cte, _ := e.cteTableFor(rightTable)
 		rightRows = cloneRows(cte.rows)
 		rightSchema = schemaFromColumns(cte.columns)
+		e.recordJoinColumns(rightAlias, cte.columns, cte.types)
 	default:
 		rightSchema, err = e.session.GetSchema(rightTable)
 		if err != nil {
@@ -2250,14 +2401,21 @@ func (e *Executor) executeJoin(tableRef parser.TableRef, leftRows []storage.Row)
 	}
 
 	rightTable := join.Table.Name
+	rightQualifier := join.Table.Alias
+	if rightQualifier == "" {
+		rightQualifier = rightTable
+	}
 	var rightRows []storage.Row
 	var err error
 	switch {
 	case join.Table.Subquery != nil:
-		rightRows, _, err = e.materializeJoinSubquery(join.Table)
+		var columns, types []string
+		rightRows, _, columns, types, err = e.materializeJoinSubquery(join.Table)
+		e.recordJoinColumns(rightQualifier, columns, types)
 	case e.cteTableExists(rightTable):
 		cte, _ := e.cteTableFor(rightTable)
 		rightRows = cloneRows(cte.rows)
+		e.recordJoinColumns(rightQualifier, cte.columns, cte.types)
 	default:
 		rightRows, err = e.session.Select(rightTable, nil)
 	}
@@ -2743,6 +2901,9 @@ func (e *Executor) applyUpsert(table string, schema *storage.Schema, existing, r
 			updates[assignment.Column] = value
 			merged[assignment.Column] = value
 		}
+		if normalizeErr := e.normalizeArrayRow(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
@@ -2770,6 +2931,9 @@ func (e *Executor) executeInsert(stmt *parser.InsertStmt) (*Result, error) {
 	if err != nil {
 		return nil, err
 	}
+	// RETURNING and conflict-clause expressions may subscript array columns.
+	e.pushTypeScope(schemaTypeResolver(schema))
+	defer e.popTypeScope()
 
 	generated := generatedColumnSet(schema)
 	if len(stmt.Columns) > 0 {
@@ -2951,6 +3115,11 @@ 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.
+	if err := e.normalizeArrayRow(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 {
@@ -3256,6 +3425,16 @@ func (e *Executor) primeSubqueries(expr parser.Expr) {
 			walk(n.Expr)
 		case *parser.CastExpr:
 			walk(n.Expr)
+		case *parser.ArrayExpr:
+			for _, element := range n.Elements {
+				walk(element)
+			}
+		case *parser.SubscriptExpr:
+			walk(n.Expr)
+			walk(n.Index)
+		case *parser.AnyAllExpr:
+			walk(n.Left)
+			walk(n.Array)
 		}
 	}
 	walk(expr)
@@ -3268,6 +3447,10 @@ func (e *Executor) executeUpdate(stmt *parser.UpdateStmt) (*Result, error) {
 	if err != nil {
 		return nil, err
 	}
+	// SET and WHERE expressions may subscript array columns; expose the target
+	// table's declared types for the duration of the statement.
+	e.pushTypeScope(schemaTypeResolver(schema))
+	defer e.popTypeScope()
 
 	generated := generatedColumnSet(schema)
 	for _, assign := range stmt.Set {
@@ -3314,6 +3497,9 @@ func (e *Executor) executeUpdate(stmt *parser.UpdateStmt) (*Result, error) {
 			updates[assign.Column] = val
 			merged[assign.Column] = val
 		}
+		if err := e.normalizeArrayRow(schema, updates); err != nil {
+			return nil, err
+		}
 		if err := e.applyGeneratedColumns(schema, merged); err != nil {
 			return nil, err
 		}
@@ -3447,6 +3633,8 @@ func applyUpdateRow(updateFn func(storage.Row) (storage.Row, error), row storage
 // the combined context, and applies the changes atomically.
 func (e *Executor) executeUpdateFrom(stmt *parser.UpdateStmt, schema *storage.Schema) (*Result, error) {
 	tableName := stmt.Table.Name
+	e.pushTypeScope(e.snapshotTypeResolver(stmt.From))
+	defer e.popTypeScope()
 
 	sources, err := e.materializeFrom(stmt.From)
 	if err != nil {
@@ -3497,6 +3685,9 @@ func (e *Executor) executeUpdateFrom(stmt *parser.UpdateStmt, schema *storage.Sc
 			}
 			row[assign.Column] = val
 		}
+		if err := e.normalizeArrayRow(schema, row); err != nil {
+			return nil, err
+		}
 		if err := e.applyGeneratedColumns(schema, row); err != nil {
 			return nil, err
 		}
@@ -3666,6 +3857,8 @@ func (e *Executor) executeDelete(stmt *parser.DeleteStmt) (*Result, error) {
 	if err != nil {
 		return nil, err
 	}
+	e.pushTypeScope(schemaTypeResolver(schema))
+	defer e.popTypeScope()
 
 	var filterErr error
 	var filter func(storage.Row) bool
@@ -3756,7 +3949,7 @@ func (e *Executor) executeCreateTable(stmt *parser.CreateTableStmt) (*Result, er
 	for _, colDef := range stmt.Columns {
 		col := storage.Column{
 			Name:     colDef.Name,
-			Type:     colDef.Type.Name,
+			Type:     colDef.Type.SQLTypeName(),
 			Nullable: true,
 		}
 
@@ -3792,6 +3985,10 @@ func (e *Executor) executeCreateTable(stmt *parser.CreateTableStmt) (*Result, er
 					if err != nil {
 						return nil, err
 					}
+					val, exprText, err = canonicalArrayDefault(col.Type, val, exprText)
+					if err != nil {
+						return nil, err
+					}
 					col.Default = val
 					col.DefaultExpr = exprText
 				}
@@ -4280,7 +4477,7 @@ func (e *Executor) executeAlterTableAddColumn(table string, action *parser.AddCo
 
 	col := storage.Column{
 		Name:     action.Column.Name,
-		Type:     action.Column.Type.Name,
+		Type:     action.Column.Type.SQLTypeName(),
 		Nullable: true,
 	}
 	var uniqueConstraint *parser.ColumnConstraint
@@ -4305,6 +4502,10 @@ func (e *Executor) executeAlterTableAddColumn(table string, action *parser.AddCo
 				if err != nil {
 					return nil, err
 				}
+				val, exprText, err = canonicalArrayDefault(col.Type, val, exprText)
+				if err != nil {
+					return nil, err
+				}
 				col.Default = val
 				col.DefaultExpr = exprText
 			}
@@ -5095,6 +5296,12 @@ func (e *Executor) evalExpr(expr parser.Expr, row storage.Row) (interface{}, err
 		return e.evalSubqueryExpr(ex, row)
 	case *parser.ExistsExpr:
 		return e.evalExistsExpr(ex, row)
+	case *parser.ArrayExpr:
+		return e.evalArrayExpr(ex, row)
+	case *parser.SubscriptExpr:
+		return e.evalSubscriptExpr(ex, row)
+	case *parser.AnyAllExpr:
+		return e.evalAnyAllExpr(ex, row)
 	default:
 		return nil, fmt.Errorf("unsupported expression type: %T", expr)
 	}
@@ -5243,6 +5450,17 @@ func (e *Executor) evalBinaryExpr(expr *parser.BinaryExpr, row storage.Row) (int
 		return nil, err
 	}
 
+	if isComparisonOp(expr.Op) {
+		left, right, err = e.canonicalizeArrayComparison(expr.Left, expr.Right, left, right)
+		if err != nil {
+			return nil, err
+		}
+		if left == nil || right == nil {
+			return nil, nil
+		}
+		return e.compareExprValues(expr.Op, expr.Left, expr.Right, left, right)
+	}
+
 	if isBitwiseOp(expr.Op) {
 		if left == nil || right == nil {
 			return nil, nil
@@ -5492,6 +5710,15 @@ func (e *Executor) applyBinaryOp(op lexer.TokenType, left, right interface{}) (i
 	}
 }
 
+// isComparisonOp reports whether op compares two values.
+func isComparisonOp(op lexer.TokenType) bool {
+	switch op {
+	case lexer.TokenEq, lexer.TokenNeq, lexer.TokenLt, lexer.TokenLte, lexer.TokenGt, lexer.TokenGte:
+		return true
+	}
+	return false
+}
+
 // isBitwiseOp reports whether op is a SQLite bitwise operator.
 func isBitwiseOp(op lexer.TokenType) bool {
 	switch op {
@@ -5924,6 +6151,13 @@ func (e *Executor) evalFunctionCall(fn *parser.FunctionCall, row storage.Row) (i
 		return evalTimediffFunc(args)
 	case "PERCENT_DIFF":
 		return evalPercentDiff(args)
+	case "ARRAY_LENGTH":
+		if len(fn.Args) != 2 {
+			return nil, fmt.Errorf("array_length requires two arguments")
+		}
+		return e.evalArrayLength(fn.Args[0], args)
+	case "UNNEST":
+		return nil, pgerror.Unsupported("unnest() is only supported as a table function in FROM, as in SELECT * FROM unnest(array)")
 	case "PIZZASQL_VERSION":
 		return version.String(), nil
 	case "SQLITE_VERSION":
@@ -6025,7 +6259,15 @@ func (e *Executor) evalInExpr(expr *parser.InExpr, row storage.Row) (interface{}
 				sawNull = true
 				continue
 			}
-			if compare(left, v) == 0 {
+			v, canonErr := e.canonicalArrayValue(expr.Left, v)
+			if canonErr != nil {
+				return nil, canonErr
+			}
+			equal, cmpErr := e.compareExprValues(lexer.TokenEq, expr.Left, nil, left, v)
+			if cmpErr != nil {
+				return nil, cmpErr
+			}
+			if equal {
 				if expr.Not {
 					return false, nil
 				}
@@ -6061,7 +6303,15 @@ func (e *Executor) evalInExpr(expr *parser.InExpr, row storage.Row) (interface{}
 			sawNull = true
 			continue
 		}
-		if compare(left, v) == 0 {
+		v, err = e.canonicalArrayValue(expr.Left, v)
+		if err != nil {
+			return nil, err
+		}
+		equal, cmpErr := e.compareExprValues(lexer.TokenEq, expr.Left, nil, left, v)
+		if cmpErr != nil {
+			return nil, cmpErr
+		}
+		if equal {
 			if expr.Not {
 				return false, nil
 			}
@@ -6191,6 +6441,10 @@ func (e *Executor) evalIsDistinctExpr(expr *parser.IsDistinctExpr, row storage.R
 	if err != nil {
 		return nil, err
 	}
+	left, right, err = e.canonicalizeArrayComparison(expr.Left, expr.Right, left, right)
+	if err != nil {
+		return nil, err
+	}
 	distinct := false
 	switch {
 	case left == nil && right == nil:
@@ -6198,7 +6452,11 @@ func (e *Executor) evalIsDistinctExpr(expr *parser.IsDistinctExpr, row storage.R
 	case left == nil || right == nil:
 		distinct = true
 	default:
-		distinct = compare(left, right) != 0
+		equal, cmpErr := e.compareExprValues(lexer.TokenEq, expr.Left, expr.Right, left, right)
+		if cmpErr != nil {
+			return nil, cmpErr
+		}
+		distinct = !equal
 	}
 	if expr.Not {
 		return !distinct, nil
@@ -6215,6 +6473,9 @@ func (e *Executor) evalCastExpr(expr *parser.CastExpr, row storage.Row) (interfa
 	if val == nil {
 		return nil, nil // CAST(NULL AS any) = NULL
 	}
+	if expr.Type.Array {
+		return evalArrayCast(expr.Type, val)
+	}
 
 	typeName := strings.ToUpper(expr.Type.Name)
 	switch {
@@ -6716,6 +6977,16 @@ func (e *Executor) evalExprWithAggregates(expr parser.Expr, rows []storage.Row)
 		if err != nil {
 			return nil, err
 		}
+		if isComparisonOp(ex.Op) {
+			left, right, err = e.canonicalizeArrayComparison(ex.Left, ex.Right, left, right)
+			if err != nil {
+				return nil, err
+			}
+			if left == nil || right == nil {
+				return nil, nil
+			}
+			return e.compareExprValues(ex.Op, ex.Left, ex.Right, left, right)
+		}
 		// Use the same logic as evalBinaryExpr to preserve integer semantics.
 		combined := &parser.BinaryExpr{Op: ex.Op}
 		return e.applyBinaryOp(combined.Op, left, right)
@@ -6769,6 +7040,9 @@ func (e *Executor) evalExprWithAggregates(expr parser.Expr, rows []storage.Row)
 		if val == nil {
 			return nil, nil
 		}
+		if ex.Type.Array {
+			return evalArrayCast(ex.Type, val)
+		}
 		switch strings.ToUpper(ex.Type.Name) {
 		case "INTEGER", "INT", "BIGINT", "SMALLINT", "TINYINT", "SIGNED":
 			if isIntVal(val) {
@@ -6891,6 +7165,52 @@ func (e *Executor) evalExprWithAggregates(expr parser.Expr, rows []storage.Row)
 			}
 			return compare(val, high) <= 0, nil
 		}
+	case *parser.ArrayExpr:
+		elements := make([]*string, len(ex.Elements))
+		for i, element := range ex.Elements {
+			value, err := e.evalExprWithAggregates(element, rows)
+			if err != nil {
+				return nil, err
+			}
+			text, err := arrayValueElement(value)
+			if err != nil {
+				return nil, fmt.Errorf("ARRAY element %d: %w", i+1, err)
+			}
+			elements[i] = text
+		}
+		return formatArrayLiteral(elements), nil
+	case *parser.SubscriptExpr:
+		value, err := e.arrayOperand(ex.Expr, func() (interface{}, error) {
+			return e.evalExprWithAggregates(ex.Expr, rows)
+		})
+		if err != nil {
+			return nil, err
+		}
+		index, err := e.evalExprWithAggregates(ex.Index, rows)
+		if err != nil {
+			return nil, err
+		}
+		return e.subscriptArrayValue(ex.Expr, value, index)
+	case *parser.AnyAllExpr:
+		left, err := e.evalExprWithAggregates(ex.Left, rows)
+		if err != nil {
+			return nil, err
+		}
+		arrayValue, err := e.arrayOperand(ex.Array, func() (interface{}, error) {
+			return e.evalExprWithAggregates(ex.Array, rows)
+		})
+		if err != nil {
+			return nil, err
+		}
+		if arrayValue == nil {
+			return nil, nil
+		}
+		elements, err := e.parsedArray(ex.Array, arrayValue)
+		if err != nil {
+			return nil, fmt.Errorf("ANY/ALL requires an array operand: %w", err)
+		}
+		elementType, _ := e.arrayElementTypeOf(ex.Array)
+		return evalQuantifiedComparison(ex.Op, ex.All, left, elements, elementType)
 	case *parser.InExpr:
 		left, err := e.evalExprWithAggregates(ex.Left, rows)
 		if err != nil {
@@ -6932,7 +7252,15 @@ func (e *Executor) evalExprWithAggregates(expr parser.Expr, rows []storage.Row)
 					sawNull = true
 					continue
 				}
-				if compare(left, v) == 0 {
+				v, canonErr := e.canonicalArrayValue(ex.Left, v)
+				if canonErr != nil {
+					return nil, canonErr
+				}
+				equal, cmpErr := e.compareExprValues(lexer.TokenEq, ex.Left, nil, left, v)
+				if cmpErr != nil {
+					return nil, cmpErr
+				}
+				if equal {
 					if ex.Not {
 						return false, nil
 					}
@@ -6965,7 +7293,15 @@ func (e *Executor) evalExprWithAggregates(expr parser.Expr, rows []storage.Row)
 				sawNull = true
 				continue
 			}
-			if compare(left, v) == 0 {
+			v, err = e.canonicalArrayValue(ex.Left, v)
+			if err != nil {
+				return nil, err
+			}
+			equal, cmpErr := e.compareExprValues(lexer.TokenEq, ex.Left, nil, left, v)
+			if cmpErr != nil {
+				return nil, cmpErr
+			}
+			if equal {
 				if ex.Not {
 					return false, nil
 				}
@@ -7056,6 +7392,17 @@ func (e *Executor) isAggregate(expr parser.Expr) bool {
 		}
 	case *parser.CastExpr:
 		return e.isAggregate(ex.Expr)
+	case *parser.ArrayExpr:
+		for _, element := range ex.Elements {
+			if e.isAggregate(element) {
+				return true
+			}
+		}
+		return false
+	case *parser.SubscriptExpr:
+		return e.isAggregate(ex.Expr) || e.isAggregate(ex.Index)
+	case *parser.AnyAllExpr:
+		return e.isAggregate(ex.Left) || e.isAggregate(ex.Array)
 	case *parser.IsNullExpr:
 		return e.isAggregate(ex.Left)
 	case *parser.IsDistinctExpr:
@@ -7648,6 +7995,16 @@ func collectColumnRefs(expr parser.Expr) []string {
 			walk(n.Expr)
 		case *parser.CastExpr:
 			walk(n.Expr)
+		case *parser.ArrayExpr:
+			for _, element := range n.Elements {
+				walk(element)
+			}
+		case *parser.SubscriptExpr:
+			walk(n.Expr)
+			walk(n.Index)
+		case *parser.AnyAllExpr:
+			walk(n.Left)
+			walk(n.Array)
 		case *parser.SubqueryExpr:
 			// Subqueries may reference outer columns
 			hasSubquery = true
@@ -7716,6 +8073,16 @@ func collectTableColumnRefs(expr parser.Expr) []tableColRef {
 			walk(n.Expr)
 		case *parser.CastExpr:
 			walk(n.Expr)
+		case *parser.ArrayExpr:
+			for _, element := range n.Elements {
+				walk(element)
+			}
+		case *parser.SubscriptExpr:
+			walk(n.Expr)
+			walk(n.Index)
+		case *parser.AnyAllExpr:
+			walk(n.Left)
+			walk(n.Array)
 		}
 	}
 	walk(expr)

+ 35 - 0
pkg/executor/indexexpr.go

@@ -125,6 +125,23 @@ func ValidateDeterministicExpr(expr parser.Expr) error {
 		return ValidateDeterministicExpr(e.Else)
 	case *parser.CastExpr:
 		return ValidateDeterministicExpr(e.Expr)
+	case *parser.ArrayExpr:
+		for _, element := range e.Elements {
+			if err := ValidateDeterministicExpr(element); err != nil {
+				return err
+			}
+		}
+		return nil
+	case *parser.SubscriptExpr:
+		if err := ValidateDeterministicExpr(e.Expr); err != nil {
+			return err
+		}
+		return ValidateDeterministicExpr(e.Index)
+	case *parser.AnyAllExpr:
+		if err := ValidateDeterministicExpr(e.Left); err != nil {
+			return err
+		}
+		return ValidateDeterministicExpr(e.Array)
 	case *parser.InExpr:
 		if e.Subquery != nil {
 			return fmt.Errorf("subqueries are not allowed in index expressions")
@@ -188,6 +205,7 @@ var deterministicFunctions = map[string]bool{
 	"REPLACE": true, "PRINTF": true, "HEX": true, "UNHEX": true,
 	"ZEROBLOB": true, "INSTR": true, "GLOB": true, "ROUND": true,
 	"MAX": true, "MIN": true, "CONCAT": true, "PERCENT_DIFF": true,
+	"ARRAY_LENGTH": true,
 	// JSON1 scalar functions.
 	"JSON": true, "JSONB": true, "JSON_VALID": true, "JSONB_VALID": true,
 	"JSON_TYPE": true, "JSON_EXTRACT": true, "JSONB_EXTRACT": true,
@@ -266,6 +284,23 @@ func ValidateIndexColumns(expr parser.Expr, schema *storage.Schema) error {
 		return ValidateIndexColumns(e.Else, schema)
 	case *parser.CastExpr:
 		return ValidateIndexColumns(e.Expr, schema)
+	case *parser.ArrayExpr:
+		for _, element := range e.Elements {
+			if err := ValidateIndexColumns(element, schema); err != nil {
+				return err
+			}
+		}
+		return nil
+	case *parser.SubscriptExpr:
+		if err := ValidateIndexColumns(e.Expr, schema); err != nil {
+			return err
+		}
+		return ValidateIndexColumns(e.Index, schema)
+	case *parser.AnyAllExpr:
+		if err := ValidateIndexColumns(e.Left, schema); err != nil {
+			return err
+		}
+		return ValidateIndexColumns(e.Array, schema)
 	case *parser.InExpr:
 		if err := ValidateIndexColumns(e.Left, schema); err != nil {
 			return err

+ 236 - 1
pkg/executor/result_types.go

@@ -13,6 +13,170 @@ import (
 // type name declared in the schema.
 type columnTypeResolver func(table, column string) (string, bool)
 
+// snapshotTypeResolver builds a column-type resolver from the FROM clause. It
+// reads every table schema up front, before the statement takes the session lock
+// for its scan, so value-time lookups never re-enter the session lock (which
+// would deadlock the scan that is asking).
+func (e *Executor) snapshotTypeResolver(refs []parser.TableRef) columnTypeResolver {
+	type tableSnapshot struct {
+		name  string
+		alias string
+		types map[string]string
+	}
+	var tables []tableSnapshot
+	for _, ref := range collectAllTableRefs(refs) {
+		if ref.Subquery != nil || ref.Function != nil || ref.Name == "" {
+			continue
+		}
+		// A materialized CTE shadows a real table with the same name; never
+		// read the table's schema for a name the query resolves as a CTE.
+		if e.cteTableExists(ref.Name) {
+			continue
+		}
+		schema, err := e.session.GetSchema(ref.Name)
+		if err != nil {
+			continue
+		}
+		snapshot := tableSnapshot{name: ref.Name, alias: ref.Alias, types: make(map[string]string, len(schema.Columns))}
+		for _, column := range schema.Columns {
+			snapshot.types[strings.ToUpper(column.Name)] = column.Type
+		}
+		tables = append(tables, snapshot)
+	}
+	return func(table, column string) (string, bool) {
+		upper := strings.ToUpper(column)
+		for _, snapshot := range tables {
+			if table != "" && !strings.EqualFold(table, snapshot.alias) && !strings.EqualFold(table, snapshot.name) {
+				continue
+			}
+			if typeName, ok := snapshot.types[upper]; ok {
+				return typeName, true
+			}
+			if table != "" {
+				break
+			}
+		}
+		return "", false
+	}
+}
+
+// joinTypeScope collects the column types of relations materialized while
+// executing a join (derived tables and CTEs on the right side). The statement's
+// WHERE and projection resolve through it after the join rows are built, so
+// array subscripts and comparisons still decode their elements.
+type joinTypeScope struct {
+	qualified   map[string]string
+	unqualified map[string]string
+}
+
+func newJoinTypeScope() *joinTypeScope {
+	return &joinTypeScope{qualified: make(map[string]string), unqualified: make(map[string]string)}
+}
+
+// record stores one column's type. Unqualified lookups keep the first relation
+// that declared the name, matching how merged join rows keep the left value.
+func (s *joinTypeScope) record(qualifier, name, typeName string) {
+	if s == nil {
+		return
+	}
+	if qualifier != "" {
+		s.qualified[strings.ToUpper(qualifier+"."+name)] = typeName
+	}
+	key := strings.ToUpper(name)
+	if _, exists := s.unqualified[key]; !exists {
+		s.unqualified[key] = typeName
+	}
+}
+
+// resolve implements columnTypeResolver over the recorded join columns.
+func (s *joinTypeScope) resolve(table, column string) (string, bool) {
+	if s == nil {
+		return "", false
+	}
+	if table != "" {
+		if typeName, ok := s.qualified[strings.ToUpper(table+"."+column)]; ok {
+			return typeName, true
+		}
+		return "", false
+	}
+	if typeName, ok := s.unqualified[strings.ToUpper(column)]; ok {
+		return typeName, true
+	}
+	return "", false
+}
+
+// recordJoinColumns adds a materialized right relation's columns to the current
+// statement's join scope.
+func (e *Executor) recordJoinColumns(qualifier string, columns, types []string) {
+	if len(e.joinTypeScopes) == 0 {
+		return
+	}
+	scope := e.joinTypeScopes[len(e.joinTypeScopes)-1]
+	for i, column := range columns {
+		scope.record(qualifier, column, typeAt(types, i))
+	}
+}
+
+// materializedTypeResolver builds a resolver over a materialized relation's
+// columns. columns and types are positionally aligned; qualifiers holds the
+// table name or alias each column can be referenced through (one entry per
+// column). A known column with no recorded type returns ("", true) so an outer
+// scope cannot supply a type for a name that is shadowed.
+func materializedTypeResolver(columns, types, qualifiers []string) columnTypeResolver {
+	if len(columns) == 0 {
+		return nil
+	}
+	return func(table, column string) (string, bool) {
+		for i, name := range columns {
+			if !strings.EqualFold(name, column) {
+				continue
+			}
+			qualifier := ""
+			if i < len(qualifiers) {
+				qualifier = qualifiers[i]
+			}
+			if table != "" && !strings.EqualFold(table, qualifier) {
+				continue
+			}
+			if i < len(types) && types[i] != "" {
+				return types[i], true
+			}
+			return "", true
+		}
+		return "", false
+	}
+}
+
+// pushTypeScope adds the column-type resolver of the SELECT or DML statement
+// being evaluated. Scopes nest: the innermost resolver wins, and an outer
+// query's resolver still answers for correlated references.
+func (e *Executor) pushTypeScope(resolve columnTypeResolver) {
+	e.typeScopes = append(e.typeScopes, resolve)
+}
+
+// popTypeScope removes the innermost resolver pushed by pushTypeScope.
+func (e *Executor) popTypeScope() {
+	if len(e.typeScopes) == 0 {
+		return
+	}
+	e.typeScopes = e.typeScopes[:len(e.typeScopes)-1]
+}
+
+// resolveColumnType resolves a column reference against the active statement
+// scopes, innermost first. It reports false when no scope declares the column,
+// which leaves the caller on the TEXT fallback.
+func (e *Executor) resolveColumnType(table, column string) (string, bool) {
+	for i := len(e.typeScopes) - 1; i >= 0; i-- {
+		if e.typeScopes[i] == nil {
+			continue
+		}
+		if typeName, ok := e.typeScopes[i](table, column); ok {
+			return typeName, true
+		}
+	}
+	return "", false
+}
+
 // schemaTypeResolver builds a resolver over a single table schema. The table
 // qualifier is ignored because the analyzer has already validated it.
 func schemaTypeResolver(schema *storage.Schema) columnTypeResolver {
@@ -149,10 +313,16 @@ func inferExprType(expr parser.Expr, resolve columnTypeResolver) string {
 	case *parser.LiteralExpr:
 		return literalType(e)
 	case *parser.CastExpr:
-		if typeName, ok := castResultType(e.Type.Name); ok {
+		if typeName, ok := castResultType(e.Type.SQLTypeName()); ok {
 			return typeName
 		}
 		return "TEXT"
+	case *parser.ArrayExpr:
+		return arrayConstructorType(e, resolve)
+	case *parser.SubscriptExpr:
+		return subscriptResultType(e, resolve)
+	case *parser.AnyAllExpr:
+		return "BOOLEAN"
 	case *parser.FunctionCall:
 		return functionResultType(e, resolve)
 	case *parser.BinaryExpr:
@@ -203,6 +373,9 @@ func literalType(lit *parser.LiteralExpr) string {
 // real targets, and it does not range-check them. A narrower advertised type
 // (int2/int4/float4) could otherwise fail to decode a value the engine accepted.
 func castResultType(name string) (string, bool) {
+	if element, isArray := arrayElementType(name); isArray {
+		return arrayResultTypeName(element), true
+	}
 	switch strings.ToUpper(strings.TrimSpace(name)) {
 	case "INTEGER", "INT", "SMALLINT", "TINYINT", "MEDIUMINT", "BIGINT":
 		return "BIGINT", true
@@ -217,6 +390,66 @@ func castResultType(name string) (string, bool) {
 	}
 }
 
+// arrayConstructorType reports the array type of an ARRAY[...] constructor. The
+// element type follows the first non-NULL element; a constructor mixing element
+// types falls back to TEXT[].
+func arrayConstructorType(expr *parser.ArrayExpr, resolve columnTypeResolver) string {
+	elementType := ""
+	for _, element := range expr.Elements {
+		typeName := inferExprType(element, resolve)
+		if isUnknownTypeName(typeName) {
+			continue
+		}
+		if elementType == "" {
+			elementType = typeName
+			continue
+		}
+		if !strings.EqualFold(elementType, typeName) {
+			elementType = "TEXT"
+		}
+	}
+	if elementType == "" {
+		elementType = "TEXT"
+	}
+	return arrayResultTypeName(elementType)
+}
+
+// subscriptResultType reports the element type of an array subscript. A
+// subscript of a non-array expression keeps the TEXT fallback.
+func subscriptResultType(expr *parser.SubscriptExpr, resolve columnTypeResolver) string {
+	if element, ok := arrayElementType(inferExprType(expr.Expr, resolve)); ok {
+		return element
+	}
+	return "TEXT"
+}
+
+// arrayResultTypeName maps an element type name to the type name the wire layer
+// reports for the array. The mapping follows the element's carrier: INTEGER is
+// int4 and REAL is float4, matching the scalar mappings, and narrower integer
+// types report int8[] because values are not range-checked. NUMERIC/DECIMAL keep
+// their own array type: array elements are validated text, so their digits are
+// not rounded through float64.
+func arrayResultTypeName(elementType string) string {
+	switch strings.ToUpper(strings.TrimSpace(elementType)) {
+	case "INTEGER", "INT", "INT4":
+		return "INTEGER[]"
+	case "BIGINT", "INT8", "SMALLINT", "INT2", "TINYINT", "MEDIUMINT":
+		return "BIGINT[]"
+	case "REAL", "FLOAT", "FLOAT4":
+		return "REAL[]"
+	case "DOUBLE", "FLOAT8":
+		return "DOUBLE[]"
+	case "NUMERIC", "DECIMAL":
+		return "NUMERIC[]"
+	case "BOOLEAN", "BOOL":
+		return "BOOLEAN[]"
+	case "VARCHAR", "NVARCHAR", "CHARACTER VARYING":
+		return "VARCHAR[]"
+	default:
+		return "TEXT[]"
+	}
+}
+
 // functionResultType reports the result type of a function call. Aggregate
 // result types mirror the values the executor computes: COUNT is BIGINT, SUM
 // over integers is BIGINT, SUM over float carriers is DOUBLE, and AVG is always
@@ -231,6 +464,8 @@ func functionResultType(fn *parser.FunctionCall, resolve columnTypeResolver) str
 		return "DOUBLE"
 	case "AVG":
 		return "DOUBLE"
+	case "ARRAY_LENGTH":
+		return "INTEGER"
 	case "MIN", "MAX":
 		// MIN/MAX return one of the input values unchanged.
 		return firstArgType(fn, resolve)

+ 17 - 0
pkg/httpserver/handler.go

@@ -11,12 +11,14 @@ import (
 
 	"github.com/goccy/go-json"
 
+	"github.com/danfragoso/pizzasql-next/pkg/analyzer"
 	"github.com/danfragoso/pizzasql-next/pkg/capabilities"
 	"github.com/danfragoso/pizzasql-next/pkg/csvexport"
 	"github.com/danfragoso/pizzasql-next/pkg/csvimport"
 	"github.com/danfragoso/pizzasql-next/pkg/executor"
 	"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/sqlexport"
 	"github.com/danfragoso/pizzasql-next/pkg/sqlimport"
 	"github.com/danfragoso/pizzasql-next/pkg/sqliteimport"
@@ -534,6 +536,21 @@ func sqlStateHTTPError(err error) *HTTPError {
 			Message: message,
 		}
 	}
+	if errors.Is(err, pgerror.ErrInvalidText) {
+		return &HTTPError{
+			Status:  http.StatusBadRequest,
+			Code:    pgerror.CodeInvalidText,
+			Message: message,
+		}
+	}
+	var analysisErr *analyzer.AnalysisError
+	if errors.Is(err, pgerror.ErrDatatypeMismatch) || (errors.As(err, &analysisErr) && analysisErr.Type == analyzer.ErrTypeMismatch) {
+		return &HTTPError{
+			Status:  http.StatusBadRequest,
+			Code:    pgerror.CodeDatatypeMismatch,
+			Message: message,
+		}
+	}
 	return &HTTPError{
 		Status:  http.StatusInternalServerError,
 		Code:    "EXECUTION_ERROR",

+ 56 - 4
pkg/lexer/lexer.go

@@ -14,6 +14,10 @@ type Lexer struct {
 	ch      byte // current char under examination
 	line    int  // current line number (1-based)
 	column  int  // current column number (1-based)
+	// prev is the type of the last non-comment token, used to disambiguate a
+	// leading bracket: after an expression it starts a subscript, otherwise it
+	// is a [bracket-quoted identifier] as in SQLite.
+	prev TokenType
 }
 
 // New creates a new Lexer for the given input.
@@ -194,7 +198,28 @@ func (l *Lexer) NextToken() Token {
 	case '`':
 		tok = l.readQuotedIdentifier('`')
 	case '[':
-		tok = l.readBracketIdentifier()
+		if l.afterExpression() {
+			tok.Type = TokenLBracket
+			tok.Literal = "["
+			l.readChar()
+		} else {
+			tok = l.readBracketIdentifier()
+		}
+	case ']':
+		tok.Type = TokenRBracket
+		tok.Literal = "]"
+		l.readChar()
+	case ':':
+		if l.peekChar() == ':' {
+			l.readChar()
+			tok.Type = TokenColonColon
+			tok.Literal = "::"
+			l.readChar()
+		} else {
+			tok.Type = TokenError
+			tok.Literal = "unexpected character: :"
+			l.readChar()
+		}
 	default:
 		if (l.ch == 'x' || l.ch == 'X') && l.peekChar() == '\'' {
 			tok = l.readBlob()
@@ -209,9 +234,35 @@ func (l *Lexer) NextToken() Token {
 		}
 	}
 
+	if tok.Type != TokenComment {
+		l.prev = tok.Type
+	}
 	return tok
 }
 
+// afterExpression reports whether a bracket at this position starts a subscript
+// rather than a bracket-quoted identifier. Only tokens that can end an
+// expression qualify; everything else keeps SQLite's [identifier] quoting (a
+// bracket-quoted alias or name directly after a keyword is still accepted).
+func (l *Lexer) afterExpression() bool {
+	switch l.prev {
+	case TokenIdent, TokenString, TokenNumber, TokenBlob,
+		TokenRParen, TokenRBracket, TokenTRUE, TokenFALSE, TokenNULL:
+		return true
+	}
+	// Type keywords accept a trailing [] and are therefore followed by a
+	// bracket that starts an array dimension, not an identifier.
+	switch l.prev {
+	case TokenINTEGER, TokenINT, TokenTINYINT, TokenSMALLINT, TokenMEDIUMINT, TokenBIGINT,
+		TokenREAL, TokenFLOAT, TokenDOUBLE, TokenNUMERIC, TokenDECIMAL,
+		TokenTEXT, TokenVARCHAR, TokenCHAR, TokenCHARACTER, TokenCLOB, TokenNCHAR, TokenNVARCHAR,
+		TokenBLOB, TokenBOOLEAN, TokenDATE, TokenTIME, TokenTIMESTAMP, TokenDATETIME,
+		TokenJSON, TokenJSONB:
+		return true
+	}
+	return false
+}
+
 // skipWhitespace skips spaces, tabs, and newlines.
 func (l *Lexer) skipWhitespace() {
 	for l.ch == ' ' || l.ch == '\t' || l.ch == '\n' || l.ch == '\r' {
@@ -327,9 +378,10 @@ func (l *Lexer) readQuotedIdentifier(quote byte) Token {
 // readBracketIdentifier reads a [bracket identifier] (SQL Server style).
 func (l *Lexer) readBracketIdentifier() Token {
 	tok := Token{
-		Type:   TokenIdent,
-		Line:   l.line,
-		Column: l.column,
+		Type:      TokenIdent,
+		Line:      l.line,
+		Column:    l.column,
+		Bracketed: true,
 	}
 
 	l.readChar() // skip [

+ 44 - 0
pkg/lexer/lexer_test.go

@@ -564,3 +564,47 @@ func BenchmarkLexer(b *testing.B) {
 		}
 	}
 }
+
+func TestLexerArraySyntaxTokens(t *testing.T) {
+	tokens := New(`arr[1]::TEXT[]`).Tokenize()
+	want := []TokenType{
+		TokenIdent, // arr
+		TokenLBracket,
+		TokenNumber,
+		TokenRBracket,
+		TokenColonColon,
+		TokenTEXT,
+		TokenLBracket,
+		TokenRBracket,
+		TokenEOF,
+	}
+	if len(tokens) != len(want) {
+		t.Fatalf("got %d tokens, want %d: %v", len(tokens), len(want), tokens)
+	}
+	for i := range want {
+		if tokens[i].Type != want[i] {
+			t.Errorf("token[%d] = %v (%q), want %v", i, tokens[i].Type, tokens[i].Literal, want[i])
+		}
+	}
+}
+
+func TestLexerBracketIdentifierAfterKeyword(t *testing.T) {
+	for _, tc := range []struct {
+		input   string
+		literal string
+	}{
+		{"SELECT [column] FROM t", "column"},
+		{"SELECT t.[column] FROM t", "column"},
+	} {
+		tokens := New(tc.input).Tokenize()
+		found := false
+		for _, token := range tokens {
+			if token.Type == TokenIdent && token.Literal == tc.literal {
+				found = true
+			}
+		}
+		if !found {
+			t.Errorf("%q did not lex %q as an identifier: %v", tc.input, tc.literal, tokens)
+		}
+	}
+}

+ 12 - 0
pkg/lexer/token.go

@@ -199,6 +199,11 @@ const (
 	TokenCONFLICT
 	TokenDO
 	TokenNOTHING
+
+	// PostgreSQL-style syntax
+	TokenColonColon // ::
+	TokenLBracket   // [ (only when it subscripts an expression)
+	TokenRBracket   // ]
 )
 
 var keywords = map[string]TokenType{
@@ -373,6 +378,10 @@ type Token struct {
 	Literal string
 	Line    int
 	Column  int
+	// Bracketed is set on an identifier that came from SQLite's [bracket]
+	// quoting, which lets the parser tell an identifier apart from a nested
+	// array literal in ARRAY[...] context.
+	Bracketed bool
 }
 
 func (t Token) String() string {
@@ -422,6 +431,9 @@ var tokenNames = map[TokenType]string{
 	TokenComma:      ",",
 	TokenSemicolon:  ";",
 	TokenDot:        ".",
+	TokenColonColon: "::",
+	TokenLBracket:   "[",
+	TokenRBracket:   "]",
 }
 
 func (t TokenType) String() string {

+ 202 - 7
pkg/parser/arrays_test.go

@@ -8,12 +8,137 @@ import (
 	"github.com/danfragoso/pizzasql-next/pkg/pgerror"
 )
 
-func TestArrayParametersAndAnyPredicatesRejected(t *testing.T) {
+func parseOne(t *testing.T, sql string) Statement {
+	t.Helper()
+	stmt, err := New(lexer.New(sql)).Parse()
+	if err != nil {
+		t.Fatalf("parse %q: %v", sql, err)
+	}
+	return stmt
+}
+
+func TestArrayColumnTypesParse(t *testing.T) {
+	stmt := parseOne(t, `CREATE TABLE t (
+		a TEXT[],
+		b VARCHAR(20)[],
+		c INTEGER[],
+		d BIGINT[],
+		e SMALLINT[],
+		f BOOLEAN[],
+		g REAL[],
+		h DOUBLE PRECISION[],
+		i NUMERIC(10,2)[]
+	)`).(*CreateTableStmt)
+
+	want := []string{
+		"TEXT[]", "VARCHAR[]", "INTEGER[]", "BIGINT[]", "SMALLINT[]",
+		"BOOLEAN[]", "REAL[]", "DOUBLE[]", "NUMERIC[]",
+	}
+	if len(stmt.Columns) != len(want) {
+		t.Fatalf("got %d columns, want %d", len(stmt.Columns), len(want))
+	}
+	for i, column := range stmt.Columns {
+		if got := column.Type.SQLTypeName(); got != want[i] {
+			t.Errorf("column %s type = %q, want %q", column.Name, got, want[i])
+		}
+		if !column.Type.Array {
+			t.Errorf("column %s is not marked as an array", column.Name)
+		}
+	}
+}
+
+func TestAlterTableAddArrayColumnParses(t *testing.T) {
+	stmt := parseOne(t, `ALTER TABLE t ADD COLUMN tags TEXT[]`).(*AlterTableStmt)
+	action, ok := stmt.Action.(*AddColumnAction)
+	if !ok {
+		t.Fatalf("action = %T, want *AddColumnAction", stmt.Action)
+	}
+	if got := action.Column.Type.SQLTypeName(); got != "TEXT[]" {
+		t.Fatalf("added column type = %q, want TEXT[]", got)
+	}
+}
+
+func TestArrayConstructorAndSubscriptParse(t *testing.T) {
+	stmt := parseOne(t, `SELECT ARRAY[1, 'a', NULL], arr[2], (ARRAY[1,2])[1] FROM t`).(*SelectStmt)
+	if len(stmt.Columns) != 3 {
+		t.Fatalf("got %d projections, want 3", len(stmt.Columns))
+	}
+	constructor, ok := stmt.Columns[0].Expr.(*ArrayExpr)
+	if !ok {
+		t.Fatalf("projection 0 = %T, want *ArrayExpr", stmt.Columns[0].Expr)
+	}
+	if len(constructor.Elements) != 3 {
+		t.Fatalf("constructor elements = %d, want 3", len(constructor.Elements))
+	}
+	if _, ok := stmt.Columns[1].Expr.(*SubscriptExpr); !ok {
+		t.Fatalf("projection 1 = %T, want *SubscriptExpr", stmt.Columns[1].Expr)
+	}
+	paren, ok := stmt.Columns[2].Expr.(*SubscriptExpr)
+	if !ok {
+		t.Fatalf("projection 2 = %T, want *SubscriptExpr", stmt.Columns[2].Expr)
+	}
+	if _, ok := paren.Expr.(*ParenExpr); !ok {
+		t.Fatalf("subscript base = %T, want *ParenExpr", paren.Expr)
+	}
+}
+
+func TestArrayCastForms(t *testing.T) {
+	for _, sql := range []string{
+		`SELECT '{1,2}'::INTEGER[]`,
+		`SELECT CAST('{1,2}' AS INTEGER[])`,
+		`SELECT '{1,2}'::DOUBLE PRECISION[]`,
+		`SELECT '{a,b}'::VARCHAR(5)[]`,
+	} {
+		stmt := parseOne(t, sql).(*SelectStmt)
+		cast, ok := stmt.Columns[0].Expr.(*CastExpr)
+		if !ok {
+			t.Fatalf("%q expression = %T, want *CastExpr", sql, stmt.Columns[0].Expr)
+		}
+		if !cast.Type.Array {
+			t.Fatalf("%q cast type is not an array", sql)
+		}
+	}
+}
+
+func TestAnyAllPredicatesParse(t *testing.T) {
+	for _, sql := range []string{
+		`SELECT * FROM t WHERE id = ANY('{1,2,3}')`,
+		`SELECT * FROM t WHERE id <> ALL('{1,2,3}')`,
+		`SELECT * FROM t WHERE id = ANY(ARRAY[1,2])`,
+		`SELECT * FROM t WHERE id >= ALL(ARRAY[1,2])`,
+		`SELECT * FROM t WHERE NOT (id = ANY('{1,2}'))`,
+	} {
+		stmt := parseOne(t, sql).(*SelectStmt)
+		if stmt.Where == nil {
+			t.Fatalf("%q has no WHERE", sql)
+		}
+	}
+}
+
+func TestAnyAllRejections(t *testing.T) {
+	for _, sql := range []string{
+		`SELECT * FROM t WHERE id = ANY(SELECT id FROM t)`,
+		`SELECT any(id) FROM t`,
+		`SELECT * FROM t WHERE id = ANY()`,
+	} {
+		_, err := New(lexer.New(sql)).Parse()
+		if !errors.Is(err, pgerror.ErrFeatureNotSupported) && !errors.Is(err, pgerror.ErrInvalidText) {
+			// A malformed operand is a parse error; the unsupported forms must
+			// report feature-not-supported.
+			if err == nil {
+				t.Errorf("%q parsed, want an error", sql)
+			}
+		}
+	}
+}
+
+func TestMultidimensionalAndUnsupportedElementArraysRejected(t *testing.T) {
 	for _, sql := range []string{
-		"SELECT * FROM t WHERE id = ANY('{1,2,3}')",
-		"SELECT * FROM t WHERE id <> ANY('{1,2,3}')",
-		"SELECT * FROM t WHERE id = ALL('{1,2,3}')",
-		"SELECT * FROM t WHERE id = ANY(ARRAY[1,2])",
+		`CREATE TABLE t (a INTEGER[][])`,
+		`CREATE TABLE t (a BLOB[])`,
+		`CREATE TABLE t (a DATE[])`,
+		`SELECT ARRAY[ARRAY[1,2]]`,
+		`SELECT ARRAY[[1,2],[3,4]]`,
 	} {
 		t.Run(sql, func(t *testing.T) {
 			_, err := New(lexer.New(sql)).Parse()
@@ -24,10 +149,80 @@ func TestArrayParametersAndAnyPredicatesRejected(t *testing.T) {
 	}
 }
 
+func TestUnnestInFromParses(t *testing.T) {
+	stmt := parseOne(t, `SELECT * FROM unnest('{1,2}'::INTEGER[]) AS u`).(*SelectStmt)
+	if len(stmt.From) != 1 {
+		t.Fatalf("FROM entries = %d, want 1", len(stmt.From))
+	}
+	if stmt.From[0].Function == nil || stmt.From[0].Function.Name != "UNNEST" {
+		t.Fatalf("FROM[0].Function = %+v, want UNNEST", stmt.From[0].Function)
+	}
+	if stmt.From[0].Alias != "u" {
+		t.Fatalf("alias = %q, want u", stmt.From[0].Alias)
+	}
+}
+
+func TestUnnestInJoinRejected(t *testing.T) {
+	_, err := New(lexer.New(`SELECT * FROM t, unnest('{1,2}'::INTEGER[]) AS u`)).Parse()
+	if !errors.Is(err, pgerror.ErrFeatureNotSupported) {
+		t.Fatalf("error = %v, want ErrFeatureNotSupported", err)
+	}
+}
+
 func TestAnyAsOrdinaryColumnStillParses(t *testing.T) {
-	// ANY is only a predicate keyword when followed by "(". A column literally
-	// named any must keep working.
+	// ANY is only a predicate keyword when it follows a comparison operator. A
+	// column literally named any must keep working.
 	if _, err := New(lexer.New("SELECT any FROM t")).Parse(); err != nil {
 		t.Fatalf("parse: %v", err)
 	}
 }
+
+func TestBracketIdentifierStillParses(t *testing.T) {
+	stmt := parseOne(t, "SELECT [bracket] FROM [table]").(*SelectStmt)
+	ref, ok := stmt.Columns[0].Expr.(*ColumnRef)
+	if !ok || ref.Column != "bracket" {
+		t.Fatalf("projection = %#v, want bracket column", stmt.Columns[0].Expr)
+	}
+	if stmt.From[0].Name != "table" {
+		t.Fatalf("FROM = %q, want table", stmt.From[0].Name)
+	}
+}
+
+// A bracket directly after an expression starts an array subscript, so the
+// SQLite bracket-quoted alias in that position is read as a subscript. The
+// projection shapes below therefore parse as subscripts and fail later (the
+// base is not an array); a bracket alias on a FROM entry has no subscript
+// position and is a syntax error. Bracket identifiers keep working wherever a
+// name is expected (after a keyword or a comma).
+func TestBracketAliasShapesAfterExpression(t *testing.T) {
+	for _, sql := range []string{
+		`SELECT count(*) [cnt] FROM t`,
+		`SELECT 1 [x]`,
+	} {
+		t.Run(sql, func(t *testing.T) {
+			stmt := parseOne(t, sql).(*SelectStmt)
+			if _, ok := stmt.Columns[0].Expr.(*SubscriptExpr); !ok {
+				t.Fatalf("%q projection = %T, want *SubscriptExpr", sql, stmt.Columns[0].Expr)
+			}
+		})
+	}
+	if _, err := New(lexer.New(`SELECT * FROM t [a]`)).Parse(); err == nil {
+		t.Fatal("bracket alias on a FROM entry parsed, want a syntax error")
+	}
+
+	// `[x]` after an expression is a subscript, not an alias.
+	stmt := parseOne(t, `SELECT arr[1] FROM t`).(*SelectStmt)
+	if _, ok := stmt.Columns[0].Expr.(*SubscriptExpr); !ok {
+		t.Fatalf("projection = %T, want *SubscriptExpr", stmt.Columns[0].Expr)
+	}
+
+	// Name positions after a keyword or comma still quote with brackets.
+	for _, sql := range []string{
+		`SELECT [col], [col2] FROM [tbl]`,
+		`INSERT INTO [tbl] ([col]) VALUES (1)`,
+	} {
+		if _, err := New(lexer.New(sql)).Parse(); err != nil {
+			t.Errorf("%q should keep bracket identifiers: %v", sql, err)
+		}
+	}
+}

+ 46 - 2
pkg/parser/ast.go

@@ -87,6 +87,9 @@ 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 *FunctionCall
 }
 
 // JoinClause represents a JOIN clause.
@@ -208,8 +211,19 @@ type ColumnDef struct {
 // DataType represents a SQL data type.
 type DataType struct {
 	Name      string
-	Precision int // for VARCHAR(n), NUMERIC(p,s)
-	Scale     int // for NUMERIC(p,s)
+	Precision int  // for VARCHAR(n), NUMERIC(p,s)
+	Scale     int  // for NUMERIC(p,s)
+	Array     bool // trailing [] declares a one-dimensional array
+}
+
+// SQLTypeName returns the type name as it is persisted in the catalog: the
+// base name plus a [] suffix for arrays. Precision and scale are not part of
+// the catalog shape because the engine does not enforce them.
+func (d DataType) SQLTypeName() string {
+	if d.Array {
+		return d.Name + "[]"
+	}
+	return d.Name
 }
 
 // ColumnConstraint represents a column-level constraint.
@@ -673,6 +687,36 @@ type ExistsExpr struct {
 func (e *ExistsExpr) node()     {}
 func (e *ExistsExpr) exprNode() {}
 
+// ArrayExpr represents the ARRAY[...] constructor. Elements are evaluated as
+// ordinary expressions; NULL elements survive as NULL array elements.
+type ArrayExpr struct {
+	Elements []Expr
+}
+
+func (e *ArrayExpr) node()     {}
+func (e *ArrayExpr) exprNode() {}
+
+// SubscriptExpr represents a 1-based array element access, arr[index].
+type SubscriptExpr struct {
+	Expr  Expr
+	Index Expr
+}
+
+func (e *SubscriptExpr) node()     {}
+func (e *SubscriptExpr) exprNode() {}
+
+// AnyAllExpr represents `left op ANY(array)` (All=false) or
+// `left op ALL(array)` (All=true). It evaluates with SQL three-valued logic.
+type AnyAllExpr struct {
+	Left  Expr
+	Op    lexer.TokenType
+	All   bool
+	Array Expr
+}
+
+func (e *AnyAllExpr) node()     {}
+func (e *AnyAllExpr) exprNode() {}
+
 // ParenExpr represents a parenthesized expression.
 type ParenExpr struct {
 	Expr Expr

+ 15 - 1
pkg/parser/format.go

@@ -59,7 +59,21 @@ func FormatExpr(expr Expr) string {
 		}
 		return strings.ToLower(e.Name) + "(" + prefix + strings.Join(args, ", ") + ")"
 	case *CastExpr:
-		return fmt.Sprintf("CAST(%s AS %s)", FormatExpr(e.Expr), e.Type.Name)
+		return fmt.Sprintf("CAST(%s AS %s)", FormatExpr(e.Expr), e.Type.SQLTypeName())
+	case *ArrayExpr:
+		elements := make([]string, len(e.Elements))
+		for i, element := range e.Elements {
+			elements[i] = FormatExpr(element)
+		}
+		return "ARRAY[" + strings.Join(elements, ", ") + "]"
+	case *SubscriptExpr:
+		return fmt.Sprintf("%s[%s]", FormatExpr(e.Expr), FormatExpr(e.Index))
+	case *AnyAllExpr:
+		quantifier := "ANY"
+		if e.All {
+			quantifier = "ALL"
+		}
+		return fmt.Sprintf("(%s %s %s(%s))", FormatExpr(e.Left), operatorString(e.Op), quantifier, FormatExpr(e.Array))
 	case *CaseExpr:
 		var b strings.Builder
 		b.WriteString("CASE")

+ 202 - 13
pkg/parser/parser.go

@@ -499,7 +499,7 @@ func (p *Parser) parseSelectColumns() ([]SelectColumn, error) {
 func (p *Parser) parseTableRefs() ([]TableRef, error) {
 	var tables []TableRef
 
-	table, err := p.parseTableRef()
+	table, err := p.parseTableRef(true)
 	if err != nil {
 		return nil, err
 	}
@@ -509,7 +509,7 @@ func (p *Parser) parseTableRefs() ([]TableRef, error) {
 	for {
 		if p.curTokenIs(lexer.TokenComma) {
 			p.nextToken()
-			table, err := p.parseTableRef()
+			table, err := p.parseTableRef(true)
 			if err != nil {
 				return nil, err
 			}
@@ -539,10 +539,19 @@ func (p *Parser) parseTableRefs() ([]TableRef, error) {
 		}
 	}
 
+	for _, table := range tables {
+		if table.Function == nil {
+			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 tables, nil
 }
 
-func (p *Parser) parseTableRef() (*TableRef, error) {
+func (p *Parser) parseTableRef(allowFunction bool) (*TableRef, error) {
 	ref := &TableRef{}
 
 	// Check for subquery (SELECT ...)
@@ -608,6 +617,31 @@ func (p *Parser) parseTableRef() (*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.
+	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)
+		}
+		call, err := p.parseFunctionCall(ref.Name)
+		if err != nil {
+			return nil, err
+		}
+		fn, ok := call.(*FunctionCall)
+		if !ok {
+			return nil, p.curError("invalid table function")
+		}
+		ref.Function = fn
+		if p.curTokenIs(lexer.TokenAS) {
+			p.nextToken()
+		}
+		if p.curTokenIs(lexer.TokenIdent) && !p.isClauseKeyword() {
+			ref.Alias = p.curToken.Literal
+			p.nextToken()
+		}
+		return ref, nil
+	}
+
 	// Check for schema.table
 	if p.curTokenIs(lexer.TokenDot) {
 		p.nextToken()
@@ -732,7 +766,7 @@ func (p *Parser) parseJoin() (*JoinClause, error) {
 	}
 
 	// Parse table reference
-	table, err := p.parseTableRef()
+	table, err := p.parseTableRef(true)
 	if err != nil {
 		return nil, err
 	}
@@ -845,7 +879,7 @@ func (p *Parser) parseInsert() (*InsertStmt, error) {
 	p.nextToken()
 
 	// Parse table name
-	table, err := p.parseTableRef()
+	table, err := p.parseTableRef(false)
 	if err != nil {
 		return nil, err
 	}
@@ -1006,7 +1040,7 @@ func (p *Parser) parseUpdate() (*UpdateStmt, error) {
 	p.nextToken() // consume UPDATE
 
 	// Parse table name
-	table, err := p.parseTableRef()
+	table, err := p.parseTableRef(false)
 	if err != nil {
 		return nil, err
 	}
@@ -1087,7 +1121,7 @@ func (p *Parser) parseDelete() (*DeleteStmt, error) {
 	p.nextToken()
 
 	// Parse table name
-	table, err := p.parseTableRef()
+	table, err := p.parseTableRef(false)
 	if err != nil {
 		return nil, err
 	}
@@ -1170,7 +1204,7 @@ func (p *Parser) parseCreateTable() (*CreateTableStmt, error) {
 	}
 
 	// Parse table name
-	table, err := p.parseTableRef()
+	table, err := p.parseTableRef(false)
 	if err != nil {
 		return nil, err
 	}
@@ -1337,6 +1371,12 @@ func (p *Parser) parseDataType() (*DataType, error) {
 		p.nextToken()
 	}
 
+	// DOUBLE PRECISION is the PostgreSQL spelling of the DOUBLE type. PRECISION
+	// is not a reserved word, so consume it as a trailing type qualifier.
+	if dt.Name == "DOUBLE" && p.curTokenIs(lexer.TokenIdent) && strings.EqualFold(p.curToken.Literal, "PRECISION") {
+		p.nextToken()
+	}
+
 	// Check for precision/scale
 	if p.curTokenIs(lexer.TokenLParen) {
 		p.nextToken()
@@ -1362,9 +1402,41 @@ func (p *Parser) parseDataType() (*DataType, error) {
 		p.nextToken()
 	}
 
+	// A single trailing [] declares a one-dimensional array. Only element types
+	// the engine can encode are accepted.
+	if p.curTokenIs(lexer.TokenLBracket) {
+		if !p.peekTokenIs(lexer.TokenRBracket) {
+			return nil, p.curError("expected ] after [ in array type")
+		}
+		p.nextToken() // [
+		p.nextToken() // ]
+		if p.curTokenIs(lexer.TokenLBracket) {
+			return nil, pgerror.Unsupported("multidimensional arrays are not supported; declare a single trailing []")
+		}
+		if !isSupportedArrayElementType(dt.Name) {
+			return nil, pgerror.Unsupported("arrays of %s are not supported; supported element types are TEXT, VARCHAR, INTEGER, BIGINT, SMALLINT, BOOLEAN, REAL, DOUBLE PRECISION, and NUMERIC", dt.Name)
+		}
+		dt.Array = true
+	}
+
 	return dt, nil
 }
 
+// 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.
+func isSupportedArrayElementType(name string) bool {
+	switch strings.ToUpper(name) {
+	case "TEXT", "VARCHAR", "CHAR", "CHARACTER", "CLOB", "NCHAR", "NVARCHAR",
+		"INTEGER", "INT", "TINYINT", "SMALLINT", "MEDIUMINT", "BIGINT",
+		"BOOLEAN", "BOOL",
+		"REAL", "FLOAT", "DOUBLE", "NUMERIC", "DECIMAL":
+		return true
+	default:
+		return false
+	}
+}
+
 func (p *Parser) isDataTypeKeyword() bool {
 	switch p.curToken.Type {
 	case lexer.TokenINTEGER, lexer.TokenINT, lexer.TokenTINYINT, lexer.TokenSMALLINT, lexer.TokenMEDIUMINT, lexer.TokenBIGINT,
@@ -1860,7 +1932,7 @@ func (p *Parser) parseDropTable() (*DropTableStmt, error) {
 
 	// Parse table names
 	for {
-		table, err := p.parseTableRef()
+		table, err := p.parseTableRef(false)
 		if err != nil {
 			return nil, err
 		}
@@ -1955,7 +2027,7 @@ func (p *Parser) parseDropView() (*DropViewStmt, error) {
 	}
 
 	for {
-		view, err := p.parseTableRef()
+		view, err := p.parseTableRef(false)
 		if err != nil {
 			return nil, err
 		}
@@ -2515,10 +2587,27 @@ func (p *Parser) parseComparisonExpr() (Expr, error) {
 		return nil, p.curError("expected IN, BETWEEN, or LIKE after NOT")
 	}
 
-	// Handle comparison operators
+	// Handle comparison operators, including the quantified forms
+	// `expr op ANY(array)` and `expr op ALL(array)`.
 	if isComparisonOp(p.curToken.Type) {
 		op := p.curToken.Type
 		p.nextToken()
+		if p.curTokenIs(lexer.TokenALL) && p.peekTokenIs(lexer.TokenLParen) {
+			p.nextToken() // ALL
+			array, err := p.parseQuantifierArray()
+			if err != nil {
+				return nil, err
+			}
+			return &AnyAllExpr{Left: left, Op: op, All: true, Array: array}, nil
+		}
+		if p.curTokenIs(lexer.TokenIdent) && strings.EqualFold(p.curToken.Literal, "ANY") && p.peekTokenIs(lexer.TokenLParen) {
+			p.nextToken() // ANY
+			array, err := p.parseQuantifierArray()
+			if err != nil {
+				return nil, err
+			}
+			return &AnyAllExpr{Left: left, Op: op, Array: array}, nil
+		}
 		right, err := p.parseBitwiseExpr()
 		if err != nil {
 			return nil, err
@@ -2529,6 +2618,69 @@ func (p *Parser) parseComparisonExpr() (Expr, error) {
 	return left, nil
 }
 
+// parseQuantifierArray parses the parenthesized operand of ANY(...) or
+// ALL(...). Arrays are the only supported operand; a subquery would need a
+// materialized set the executor does not build for this form.
+func (p *Parser) parseQuantifierArray() (Expr, error) {
+	if !p.curTokenIs(lexer.TokenLParen) {
+		return nil, p.curError("expected ( after ANY or ALL")
+	}
+	p.nextToken()
+	if p.curTokenIs(lexer.TokenSELECT) {
+		return nil, pgerror.Unsupported("ANY/ALL over a subquery is not supported; pass an array value instead")
+	}
+	array, err := p.parseExpr()
+	if err != nil {
+		return nil, err
+	}
+	if !p.curTokenIs(lexer.TokenRParen) {
+		return nil, p.curError("expected ) after ANY/ALL operand")
+	}
+	p.nextToken()
+	return array, nil
+}
+
+// parseArrayConstructor parses ARRAY[...] with the current token on ARRAY.
+// Nested constructors and nested array literals are rejected: PizzaSQL has no
+// multidimensional arrays.
+func (p *Parser) parseArrayConstructor() (Expr, error) {
+	p.nextToken() // ARRAY
+	if !p.curTokenIs(lexer.TokenLBracket) {
+		return nil, p.curError("expected [ after ARRAY")
+	}
+	p.nextToken()
+	expr := &ArrayExpr{}
+	if p.curTokenIs(lexer.TokenRBracket) {
+		p.nextToken()
+		return expr, nil
+	}
+	for {
+		// A bracketed identifier here is ARRAY[[...]] (or ARRAY[[col]]): the
+		// lexer reads the inner brackets as SQLite quoting, so report the
+		// nested-array shape explicitly instead of resolving a bogus column.
+		if p.curTokenIs(lexer.TokenIdent) && p.curToken.Bracketed {
+			return nil, pgerror.Unsupported("multidimensional arrays are not supported; ARRAY[...] elements must be scalars")
+		}
+		element, err := p.parseExpr()
+		if err != nil {
+			return nil, err
+		}
+		if _, nested := element.(*ArrayExpr); nested {
+			return nil, pgerror.Unsupported("multidimensional arrays are not supported; ARRAY[...] elements must be scalars")
+		}
+		expr.Elements = append(expr.Elements, element)
+		if p.curTokenIs(lexer.TokenComma) {
+			p.nextToken()
+			continue
+		}
+		if !p.curTokenIs(lexer.TokenRBracket) {
+			return nil, p.curError("expected ] after ARRAY elements")
+		}
+		p.nextToken()
+		return expr, nil
+	}
+}
+
 func isComparisonOp(t lexer.TokenType) bool {
 	switch t {
 	case lexer.TokenEq, lexer.TokenNeq, lexer.TokenLt,
@@ -2711,11 +2863,45 @@ func (p *Parser) parseUnaryExpr() (Expr, error) {
 	return p.parsePrimaryExpr()
 }
 
+// parsePrimaryExpr parses a primary expression and applies the postfix
+// operators that bind tighter than any prefix operator: array element
+// subscripts (arr[idx]) and PostgreSQL-style casts (expr::type).
 func (p *Parser) parsePrimaryExpr() (Expr, error) {
+	expr, err := p.parsePrimaryExprBase()
+	if err != nil {
+		return nil, err
+	}
+	for {
+		switch {
+		case p.curTokenIs(lexer.TokenLBracket):
+			p.nextToken()
+			index, indexErr := p.parseExpr()
+			if indexErr != nil {
+				return nil, indexErr
+			}
+			if !p.curTokenIs(lexer.TokenRBracket) {
+				return nil, p.curError("expected ] after array subscript")
+			}
+			p.nextToken()
+			expr = &SubscriptExpr{Expr: expr, Index: index}
+		case p.curTokenIs(lexer.TokenColonColon):
+			p.nextToken()
+			dataType, typeErr := p.parseDataType()
+			if typeErr != nil {
+				return nil, typeErr
+			}
+			expr = &CastExpr{Expr: expr, Type: *dataType}
+		default:
+			return expr, nil
+		}
+	}
+}
+
+func (p *Parser) parsePrimaryExprBase() (Expr, error) {
 	switch p.curToken.Type {
 	case lexer.TokenALL:
 		if p.peekTokenIs(lexer.TokenLParen) {
-			return nil, pgerror.Unsupported("array parameters and ANY/ALL predicates are not supported (PizzaSQL has no array type); expand the list into IN (...) with individual parameters")
+			return nil, pgerror.Unsupported("ALL(...) is a quantified predicate and must follow a comparison operator, as in expr <> ALL(array)")
 		}
 		return nil, p.curError("unexpected token in expression: " + p.curToken.Type.String())
 
@@ -2803,6 +2989,9 @@ func (p *Parser) parsePrimaryExpr() (Expr, error) {
 		return p.parseIdentOrFunction()
 
 	case lexer.TokenIdent:
+		if strings.EqualFold(p.curToken.Literal, "ARRAY") && p.peekTokenIs(lexer.TokenLBracket) {
+			return p.parseArrayConstructor()
+		}
 		return p.parseIdentOrFunction()
 
 	case lexer.TokenStar:
@@ -2823,7 +3012,7 @@ func (p *Parser) parseIdentOrFunction() (Expr, error) {
 	// Check for function call
 	if p.curTokenIs(lexer.TokenLParen) {
 		if strings.EqualFold(name, "ANY") {
-			return nil, pgerror.Unsupported("array parameters and ANY/ALL predicates are not supported (PizzaSQL has no array type); expand the list into IN (...) with individual parameters")
+			return nil, pgerror.Unsupported("ANY(...) is a quantified predicate and must follow a comparison operator, as in expr = ANY(array)")
 		}
 		return p.parseFunctionCall(name)
 	}

+ 24 - 0
pkg/pgerror/pgerror.go

@@ -10,6 +10,8 @@ import (
 // SQLSTATE codes used by PizzaSQL's protocol and HTTP surfaces.
 const (
 	CodeFeatureNotSupported  = "0A000"
+	CodeInvalidText          = "22P02"
+	CodeDatatypeMismatch     = "42804"
 	CodeUniqueViolation      = "23505"
 	CodeForeignKeyViolation  = "23503"
 	CodeCheckViolation       = "23514"
@@ -28,3 +30,25 @@ var ErrFeatureNotSupported = errors.New("feature not supported")
 func Unsupported(format string, args ...interface{}) error {
 	return fmt.Errorf("%w: %s", ErrFeatureNotSupported, fmt.Sprintf(format, args...))
 }
+
+// ErrInvalidText marks input that is valid SQL but not valid text for the
+// target type, such as a malformed array literal. Protocol layers map it to
+// SQLSTATE 22P02 (invalid_text_representation).
+var ErrInvalidText = errors.New("invalid text representation")
+
+// InvalidText returns an error that wraps ErrInvalidText with a client-facing
+// explanation of the rejected input.
+func InvalidText(format string, args ...interface{}) error {
+	return fmt.Errorf("%w: %s", ErrInvalidText, 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).
+var ErrDatatypeMismatch = errors.New("datatype mismatch")
+
+// DatatypeMismatch returns an error that wraps ErrDatatypeMismatch with a
+// client-facing explanation of the incompatible value.
+func DatatypeMismatch(format string, args ...interface{}) error {
+	return fmt.Errorf("%w: %s", ErrDatatypeMismatch, fmt.Sprintf(format, args...))
+}

+ 409 - 29
pkg/pgserver/array_param_test.go

@@ -5,53 +5,433 @@ import (
 	"testing"
 )
 
-// A pg client that binds a text[] parameter for "id = ANY($1)" must receive a
-// clear feature-not-supported error instead of a silently empty result.
-func TestArrayParameterRejectedOverExtendedQuery(t *testing.T) {
-	c, client := newDataConnection(t)
-	reader := bufio.NewReader(client)
+// arrayConnection returns a connection whose output is captured in memory, so
+// both simple and extended protocol responses can be decoded.
+func arrayConnection(t *testing.T) (*Connection, *countingConn) {
+	t.Helper()
+	c, _ := newDataConnection(t)
+	conn := &countingConn{}
+	c.conn = conn
+	c.writer = bufio.NewWriter(conn)
+	return c, conn
+}
+
+// runSimple sends a simple Query and returns the decoded response messages.
+func runSimple(t *testing.T, c *Connection, conn *countingConn, sql string) []*Message {
+	t.Helper()
+	if err := c.handleMessage(&Message{Type: MsgQuery, Data: append([]byte(sql), 0)}); err != nil {
+		t.Fatalf("handleQuery %q: %v", sql, err)
+	}
+	return readAllMessages(t, conn.bytes())
+}
 
+// runExtendedParseBindExecute drives one extended-protocol cycle with a single
+// text-format parameter of the given OID.
+func runExtendedParseBindExecute(t *testing.T, c *Connection, conn *countingConn, sql string, oid int32, value string) []*Message {
+	t.Helper()
 	parse := NewMessageBuilder()
 	parse.WriteString("s1")
-	parse.WriteString("SELECT * FROM t WHERE id = ANY($1)")
+	parse.WriteString(sql)
 	parse.WriteInt16(1)
-	parse.WriteInt32(1009) // text[]
-	if err := c.handleParse(&Message{Type: MsgParse, Data: parse.Bytes()}); err != nil {
-		t.Fatalf("handleParse: %v", err)
-	}
+	parse.WriteInt32(oid)
 
 	bind := NewMessageBuilder()
 	bind.WriteString("p1")
 	bind.WriteString("s1")
 	bind.WriteInt16(0) // all-text parameter formats
-	bind.WriteInt16(1) // one parameter
-	bind.WriteInt32(5)
-	bind.WriteBytes([]byte("{1,2}"))
+	bind.WriteInt16(1)
+	bind.WriteInt32(int32(len(value)))
+	bind.WriteBytes([]byte(value))
 	bind.WriteInt16(0) // all-text result formats
-	if err := c.handleBind(&Message{Type: MsgBind, Data: bind.Bytes()}); err != nil {
-		t.Fatalf("handleBind: %v", err)
-	}
+
 	execute := NewMessageBuilder()
 	execute.WriteString("p1")
 	execute.WriteInt32(0)
-	done := make(chan error, 1)
-	// handleBind buffered BindComplete without flushing; handleExecute's error
-	// path flushes both messages, so read them in order.
-	go func() { done <- c.handleExecute(&Message{Type: MsgExecute, Data: execute.Bytes()}) }()
-	var errMsg *Message
-	for errMsg == nil {
-		msg, err := ReadMessage(reader)
-		if err != nil {
-			t.Fatalf("read response: %v", err)
+
+	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())
+}
+
+func firstDataRow(t *testing.T, messages []*Message) []string {
+	t.Helper()
+	for _, msg := range messages {
+		if msg.Type == MsgDataRow {
+			return dataRowValues(t, msg)
 		}
+	}
+	return nil
+}
+
+func findError(messages []*Message) *Message {
+	for _, msg := range messages {
 		if msg.Type == MsgErrorResponse {
-			errMsg = msg
+			return msg
+		}
+	}
+	return nil
+}
+
+// A pg client that binds text[] for "id = ANY($1)" gets the rows the array
+// describes instead of a rewritten query.
+func TestArrayParameterOverExtendedQuery(t *testing.T) {
+	c, conn := arrayConnection(t)
+	for _, sql := range []string{
+		`CREATE TABLE t (id INTEGER PRIMARY KEY, tags TEXT[])`,
+		`INSERT INTO t VALUES (1, '{a}'), (2, '{b}'), (3, '{c}')`,
+	} {
+		if _, err := c.executor.Execute(parseStmt(t, sql)); err != nil {
+			t.Fatalf("%s: %v", sql, err)
+		}
+	}
+
+	messages := runExtendedParseBindExecute(t, c, conn, `SELECT id FROM t WHERE id = ANY($1) ORDER BY id`, 1007, "{1,3}")
+	if errMsg := findError(messages); errMsg != nil {
+		t.Fatalf("extended ANY($1) failed: %s", errorCode(errMsg))
+	}
+	rows := [][]string{}
+	for _, msg := range messages {
+		if msg.Type == MsgDataRow {
+			rows = append(rows, dataRowValues(t, msg))
+		}
+	}
+	if len(rows) != 2 || rows[0][0] != "1" || rows[1][0] != "3" {
+		t.Fatalf("ANY($1) rows = %v, want [[1] [3]]", rows)
+	}
+
+	// text[] uses the same text binding path.
+	conn.reset()
+	messages = runExtendedParseBindExecute(t, c, conn, `SELECT id FROM t WHERE id = ANY($1)`, 1009, "{1,2}")
+	if errMsg := findError(messages); errMsg != nil {
+		t.Fatalf("extended text[] ANY($1) failed: %s", errorCode(errMsg))
+	}
+	if row := firstDataRow(t, messages); len(row) != 1 {
+		t.Fatalf("text[] ANY($1) returned %v", row)
+	}
+
+	// An unspecified parameter OID keeps the array literal untyped; ANY still
+	// reads it as array text, which is what drivers that omit OIDs rely on.
+	conn.reset()
+	messages = runExtendedParseBindExecute(t, c, conn, `SELECT id FROM t WHERE id = ANY($1) ORDER BY id`, 0, "{2,3}")
+	if errMsg := findError(messages); errMsg != nil {
+		t.Fatalf("extended untyped ANY($1) failed: %s", errorCode(errMsg))
+	}
+	rows = nil
+	for _, msg := range messages {
+		if msg.Type == MsgDataRow {
+			rows = append(rows, dataRowValues(t, msg))
+		}
+	}
+	if len(rows) != 2 || rows[0][0] != "2" || rows[1][0] != "3" {
+		t.Fatalf("untyped ANY($1) rows = %v, want [[2] [3]]", rows)
+	}
+
+	// NULL elements follow three-valued logic through the parameter path: rows
+	// whose comparison is NULL are not returned, rows that match are.
+	conn.reset()
+	messages = runExtendedParseBindExecute(t, c, conn, `SELECT id FROM t WHERE id = ANY($1) ORDER BY id`, 1007, "{NULL,2}")
+	if errMsg := findError(messages); errMsg != nil {
+		t.Fatalf("extended NULL-element ANY($1) failed: %s", errorCode(errMsg))
+	}
+	if row := firstDataRow(t, messages); len(row) != 1 || row[0] != "2" {
+		t.Fatalf("NULL-element ANY($1) row = %v, want [2]", row)
+	}
+
+	// An empty array parameter matches nothing and is not an error.
+	conn.reset()
+	messages = runExtendedParseBindExecute(t, c, conn, `SELECT id FROM t WHERE id = ANY($1)`, 1007, "{}")
+	if errMsg := findError(messages); errMsg != nil {
+		t.Fatalf("extended empty-array ANY($1) failed: %s", errorCode(errMsg))
+	}
+	if row := firstDataRow(t, messages); len(row) != 0 {
+		t.Fatalf("empty-array ANY($1) returned %v, want no rows", row)
+	}
+
+	// ALL keeps its dual semantics through the parameter path.
+	conn.reset()
+	messages = runExtendedParseBindExecute(t, c, conn, `SELECT id FROM t WHERE id <> ALL($1)`, 1007, "{1,2}")
+	if errMsg := findError(messages); errMsg != nil {
+		t.Fatalf("extended ALL($1) failed: %s", errorCode(errMsg))
+	}
+	if row := firstDataRow(t, messages); len(row) != 1 || row[0] != "3" {
+		t.Fatalf("ALL($1) row = %v, want [3]", row)
+	}
+}
+
+func TestArrayParameterOverSimpleQuery(t *testing.T) {
+	c, conn := arrayConnection(t)
+	if _, err := c.executor.Execute(parseStmt(t, `CREATE TABLE t (id INTEGER PRIMARY KEY)`)); err != nil {
+		t.Fatal(err)
+	}
+	for _, sql := range []string{
+		`INSERT INTO t VALUES (1), (2), (3)`,
+	} {
+		if _, err := c.executor.Execute(parseStmt(t, sql)); err != nil {
+			t.Fatalf("%s: %v", sql, err)
+		}
+	}
+	messages := runSimple(t, c, conn, `SELECT id FROM t WHERE id = ANY('{1,3}') ORDER BY id`)
+	if errMsg := findError(messages); errMsg != nil {
+		t.Fatalf("simple ANY failed: %s", errorCode(errMsg))
+	}
+	rows := [][]string{}
+	for _, msg := range messages {
+		if msg.Type == MsgDataRow {
+			rows = append(rows, dataRowValues(t, msg))
+		}
+	}
+	if len(rows) != 2 || rows[0][0] != "1" || rows[1][0] != "3" {
+		t.Fatalf("simple ANY rows = %v", rows)
+	}
+}
+
+func TestArrayResultMetadataOIDs(t *testing.T) {
+	c, conn := arrayConnection(t)
+	for _, sql := range []string{
+		`CREATE TABLE t (id INTEGER PRIMARY KEY, tags TEXT[], nums INTEGER[], big BIGINT[], small SMALLINT[], flags BOOLEAN[], reals REAL[], doubles DOUBLE PRECISION[], nums2 NUMERIC[], names VARCHAR(10)[])`,
+		`INSERT INTO t VALUES (1, '{a}', '{1}', '{1}', '{1}', '{t}', '{1.5}', '{1.5}', '{1.5}', '{x}')`,
+	} {
+		if _, err := c.executor.Execute(parseStmt(t, sql)); err != nil {
+			t.Fatalf("%s: %v", sql, err)
+		}
+	}
+	messages := runSimple(t, c, conn, `SELECT tags, nums, big, small, flags, reals, doubles, nums2, names FROM t`)
+	rd := findRowDescription(t, messages)
+	oids := rowDescriptionOIDs(t, rd.Data)
+	want := []int32{1009, 1007, 1016, 1016, 1000, 1021, 1022, 1231, 1015}
+	if len(oids) != len(want) {
+		t.Fatalf("array OIDs = %v, want %v", oids, want)
+	}
+	for i, oid := range want {
+		if oids[i] != oid {
+			t.Errorf("column %d OID = %d, want %d", i, oids[i], oid)
+		}
+	}
+
+	conn.reset()
+	messages = runSimple(t, c, conn, `SELECT '{1,2}'::INTEGER[] AS arr`)
+	rd = findRowDescription(t, messages)
+	if oid := rowDescriptionOIDs(t, rd.Data)[0]; oid != 1007 {
+		t.Fatalf("cast array OID = %d, want 1007", oid)
+	}
+	if row := firstDataRow(t, messages); row[0] != "{1,2}" {
+		t.Fatalf("cast array text = %v, want {1,2}", row)
+	}
+}
+
+func TestArrayParameterBinaryFormatRejected(t *testing.T) {
+	c, conn := arrayConnection(t)
+	if _, err := c.executor.Execute(parseStmt(t, `CREATE TABLE t (id INTEGER PRIMARY KEY)`)); err != nil {
+		t.Fatal(err)
+	}
+
+	parse := NewMessageBuilder()
+	parse.WriteString("s1")
+	parse.WriteString(`SELECT id FROM t WHERE id = ANY($1)`)
+	parse.WriteInt16(1)
+	parse.WriteInt32(1007) // int4[]
+
+	bind := NewMessageBuilder()
+	bind.WriteString("p1")
+	bind.WriteString("s1")
+	bind.WriteInt16(1) // one parameter format code
+	bind.WriteInt16(1) // binary
+	bind.WriteInt16(1) // one parameter
+	bind.WriteInt32(8)
+	bind.WriteBytes([]byte{0, 0, 0, 1, 0, 0, 0, 2})
+	bind.WriteInt16(0)
+
+	for _, msg := range []*Message{
+		{Type: MsgParse, Data: parse.Bytes()},
+		{Type: MsgBind, Data: bind.Bytes()},
+		{Type: MsgSync},
+	} {
+		if err := c.handleMessage(msg); err != nil {
+			t.Fatalf("handle %c: %v", msg.Type, err)
 		}
 	}
-	if err := <-done; err != nil {
-		t.Fatalf("handleExecute: %v", err)
+	messages := readAllMessages(t, conn.bytes())
+	errMsg := findError(messages)
+	if errMsg == nil {
+		t.Fatal("binary array parameter was accepted")
 	}
 	if code := errorCode(errMsg); code != ErrCodeFeatureNotSupported {
-		t.Fatalf("error code = %q, want %q", code, ErrCodeFeatureNotSupported)
+		t.Fatalf("binary array error code = %q, want %q", code, ErrCodeFeatureNotSupported)
+	}
+}
+
+func TestMalformedArrayParameterReportsInvalidText(t *testing.T) {
+	c, conn := arrayConnection(t)
+	for _, sql := range []string{
+		`CREATE TABLE t (id INTEGER PRIMARY KEY)`,
+		`INSERT INTO t VALUES (1)`,
+	} {
+		if _, err := c.executor.Execute(parseStmt(t, sql)); err != nil {
+			t.Fatalf("%s: %v", sql, err)
+		}
+	}
+
+	messages := runExtendedParseBindExecute(t, c, conn, `SELECT id FROM t WHERE id = ANY($1)`, 1007, "{1,2")
+	errMsg := findError(messages)
+	if errMsg == nil {
+		t.Fatal("malformed array parameter was accepted")
+	}
+	if code := errorCode(errMsg); code != ErrCodeInvalidText {
+		t.Fatalf("malformed array error code = %q, want %q", code, ErrCodeInvalidText)
+	}
+
+	conn.reset()
+	messages = runExtendedParseBindExecute(t, c, conn, `SELECT id FROM t WHERE id = ANY($1)`, 1007, "{not-an-int}")
+	errMsg = findError(messages)
+	if errMsg == nil {
+		t.Fatal("non-integer element was accepted for int4[]")
+	}
+	if code := errorCode(errMsg); code != ErrCodeInvalidText {
+		t.Fatalf("invalid element error code = %q, want %q", code, ErrCodeInvalidText)
+	}
+}
+
+// reset discards captured output so one test can inspect several responses.
+func (c *countingConn) reset() {
+	c.mu.Lock()
+	defer c.mu.Unlock()
+	c.buf.Reset()
+}
+
+// A parameter inside a subscript must bind: brackets are ordinary syntax, not a
+// quoted region.
+func TestArraySubscriptParameterBinding(t *testing.T) {
+	c, conn := arrayConnection(t)
+	for _, sql := range []string{
+		`CREATE TABLE t (id INTEGER PRIMARY KEY, tags TEXT[])`,
+		`INSERT INTO t VALUES (1, '{a,b,c}')`,
+	} {
+		if _, err := c.executor.Execute(parseStmt(t, sql)); err != nil {
+			t.Fatalf("%s: %v", sql, err)
+		}
+	}
+
+	messages := runExtendedParseBindExecute(t, c, conn, `SELECT tags[$1] FROM t`, 23, "2")
+	if errMsg := findError(messages); errMsg != nil {
+		t.Fatalf("tags[$1] failed: %s", errorCode(errMsg))
+	}
+	if row := firstDataRow(t, messages); len(row) != 1 || row[0] != "b" {
+		t.Fatalf("tags[$1] row = %v, want [b]", row)
+	}
+
+	// An untyped parameter binds the same way.
+	conn.reset()
+	messages = runExtendedParseBindExecute(t, c, conn, `SELECT tags[$1] FROM t`, 0, "3")
+	if errMsg := findError(messages); errMsg != nil {
+		t.Fatalf("untyped tags[$1] failed: %s", errorCode(errMsg))
+	}
+	if row := firstDataRow(t, messages); len(row) != 1 || row[0] != "c" {
+		t.Fatalf("untyped tags[$1] row = %v, want [c]", row)
+	}
+}
+
+// Quotes and backslashes inside a bound array element survive canonicalization,
+// SQL-literal interpolation, casting, and storage unchanged.
+func TestArrayParameterEscapedElements(t *testing.T) {
+	c, conn := arrayConnection(t)
+	if _, err := c.executor.Execute(parseStmt(t, `CREATE TABLE t (id INTEGER PRIMARY KEY, tags TEXT[])`)); err != nil {
+		t.Fatal(err)
+	}
+	const arrayText = "{\"it's\",\"back\\\\slash\"}"
+	const canonical = "{it's,\"back\\\\slash\"}"
+
+	messages := runExtendedParseBindExecute(t, c, conn, `SELECT $1::TEXT[]`, 1009, arrayText)
+	if errMsg := findError(messages); errMsg != nil {
+		t.Fatalf("escaped array cast failed: %s", errorCode(errMsg))
+	}
+	if row := firstDataRow(t, messages); len(row) != 1 || row[0] != canonical {
+		t.Fatalf("escaped array text = %v, want %s", row, canonical)
+	}
+
+	conn.reset()
+	messages = runExtendedParseBindExecute(t, c, conn, `INSERT INTO t VALUES (1, $1) RETURNING tags`, 1009, arrayText)
+	if errMsg := findError(messages); errMsg != nil {
+		t.Fatalf("escaped array insert failed: %s", errorCode(errMsg))
+	}
+	if row := firstDataRow(t, messages); len(row) != 1 || row[0] != canonical {
+		t.Fatalf("escaped array insert = %v, want %s", row, canonical)
+	}
+}
+
+func TestNumericArrayCastOID(t *testing.T) {
+	c, conn := arrayConnection(t)
+	messages := runSimple(t, c, conn, `SELECT '{1.10,12345678901234567890}'::NUMERIC[] AS amounts`)
+	if errMsg := findError(messages); errMsg != nil {
+		t.Fatalf("numeric array cast failed: %s", errorCode(errMsg))
+	}
+	rd := findRowDescription(t, messages)
+	if oid := rowDescriptionOIDs(t, rd.Data)[0]; oid != 1231 {
+		t.Fatalf("numeric array OID = %d, want 1231", oid)
+	}
+	if row := firstDataRow(t, messages); row[0] != "{1.10,12345678901234567890}" {
+		t.Fatalf("numeric array value = %v", row[0])
+	}
+}
+
+func TestArrayIndexTypeSQLState(t *testing.T) {
+	c, conn := arrayConnection(t)
+	for _, sql := range []string{
+		`CREATE TABLE t (id INTEGER PRIMARY KEY, nums INTEGER[])`,
+		`INSERT INTO t VALUES (1, '{10,20}')`,
+	} {
+		if _, err := c.executor.Execute(parseStmt(t, sql)); err != nil {
+			t.Fatalf("%s: %v", sql, err)
+		}
+	}
+	for _, sql := range []string{
+		`SELECT nums[1.5] FROM t`,
+		`SELECT nums[TRUE] FROM t`,
+		`SELECT nums['x'] FROM t`,
+		`SELECT array_length(nums, 1.5) FROM t`,
+	} {
+		conn.reset()
+		messages := runSimple(t, c, conn, sql)
+		errMsg := findError(messages)
+		if errMsg == nil {
+			t.Errorf("%s was accepted", sql)
+			continue
+		}
+		if code := errorCode(errMsg); code != ErrCodeDatatypeMismatch {
+			t.Errorf("%s SQLSTATE = %q, want %q", sql, code, ErrCodeDatatypeMismatch)
+		}
+	}
+}
+
+func TestBooleanSubscriptPredicateOverSimpleQuery(t *testing.T) {
+	c, conn := arrayConnection(t)
+	for _, sql := range []string{
+		`CREATE TABLE t (id INTEGER PRIMARY KEY, flags BOOLEAN[])`,
+		`INSERT INTO t VALUES (1, '{t,f}'), (2, '{f,t}')`,
+	} {
+		if _, err := c.executor.Execute(parseStmt(t, sql)); err != nil {
+			t.Fatalf("%s: %v", sql, err)
+		}
+	}
+	for _, tc := range []struct{ sql, want string }{
+		{`SELECT count(*) FROM t WHERE flags[1]`, "1"},
+		{`SELECT count(*) FROM t WHERE NOT flags[1]`, "1"},
+		{`SELECT count(*) FROM t WHERE flags[1] = TRUE`, "1"},
+	} {
+		conn.reset()
+		messages := runSimple(t, c, conn, tc.sql)
+		if errMsg := findError(messages); errMsg != nil {
+			t.Fatalf("%s failed: %s", tc.sql, errorCode(errMsg))
+		}
+		if row := firstDataRow(t, messages); len(row) != 1 || row[0] != tc.want {
+			t.Errorf("%s = %v, want %s", tc.sql, row, tc.want)
+		}
 	}
 }

+ 15 - 1
pkg/pgserver/compat_suite_test.go

@@ -135,7 +135,21 @@ func TestCompatibilitySuite(t *testing.T) {
 	expectRows("ddl.parenthesized_defaults_applied", `SELECT count(*) FROM paren_defaults WHERE a IS NOT NULL AND b IS NOT NULL`, "1")
 	expectCode("ddl.nonconstant_default_0A000", `CREATE TABLE bad_default (id INTEGER PRIMARY KEY, token TEXT DEFAULT lower('x'))`, "0A000")
 	expectCode("locking.for_update_0A000", `SELECT * FROM accounts FOR UPDATE`, "0A000")
-	expectCode("arrays.any_0A000", `SELECT * FROM accounts WHERE id = ANY('{1,2}')`, "0A000")
+
+	// Array types, array literals, ANY/ALL, and array parameters.
+	expectOK("arrays.ddl", `CREATE TABLE array_values (id INTEGER PRIMARY KEY, tags TEXT[], nums INTEGER[])`)
+	expectOK("arrays.insert", `INSERT INTO array_values VALUES (1, '{a,b}', '{1,2,NULL}')`)
+	expectRows("arrays.any", `SELECT count(*) FROM array_values WHERE 'a' = ANY(tags)`, "1")
+	expectRows("arrays.any_null_element", `SELECT count(*) FROM array_values WHERE 3 = ANY(nums)`, "0")
+	expectRows("arrays.all", `SELECT count(*) FROM array_values WHERE 9 <> ALL('{1,2}')`, "1")
+	expectRows("arrays.all_null_element", `SELECT count(*) FROM array_values WHERE 9 <> ALL(nums)`, "0")
+	expectRows("arrays.subscript", `SELECT tags[1] FROM array_values`, "a")
+	expectRows("arrays.literal_compare", `SELECT count(*) FROM array_values WHERE tags = '{a, b}'`, "1")
+	expectRows("arrays.numeric_exact", `SELECT '{12345678901234567890,1.10}'::NUMERIC[]`, "{12345678901234567890,1.10}")
+	expectCode("arrays.index_type_42804", `SELECT nums[1.5] FROM array_values`, "42804")
+	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")
 
 	// CHECK and FOREIGN KEY are accepted and enforced.
 	expectOK("ddl.check_accepted", `CREATE TABLE checked (n INTEGER CHECK (n > 0))`)

+ 130 - 4
pkg/pgserver/connection.go

@@ -17,6 +17,7 @@ import (
 	"strings"
 	"sync/atomic"
 
+	"github.com/danfragoso/pizzasql-next/pkg/analyzer"
 	"github.com/danfragoso/pizzasql-next/pkg/executor"
 	"github.com/danfragoso/pizzasql-next/pkg/lexer"
 	"github.com/danfragoso/pizzasql-next/pkg/parser"
@@ -426,6 +427,21 @@ func (c *Connection) handleQuery(msg *Message) error {
 			if errors.Is(err, storage.ErrSerialization) {
 				code = ErrCodeSerializationFailure
 				c.txStatus = TxStatusIdle
+			} else if errors.Is(err, pgerror.ErrInvalidText) {
+				code = ErrCodeInvalidText
+				if c.txStatus == TxStatusInBlock {
+					c.txStatus = TxStatusFailed
+				}
+			} else if errors.Is(err, pgerror.ErrDatatypeMismatch) {
+				code = ErrCodeDatatypeMismatch
+				if c.txStatus == TxStatusInBlock {
+					c.txStatus = TxStatusFailed
+				}
+			} else if analysisErr := analysisTypeMismatch(err); analysisErr {
+				code = ErrCodeDatatypeMismatch
+				if c.txStatus == TxStatusInBlock {
+					c.txStatus = TxStatusFailed
+				}
 			} else if errors.As(err, &notNullErr) {
 				code = "23502"
 				if _, commit := stmt.(*parser.CommitStmt); commit {
@@ -585,6 +601,9 @@ func (c *Connection) handleBind(msg *Message) error {
 		if errors.Is(err, pgerror.ErrFeatureNotSupported) {
 			code = ErrCodeFeatureNotSupported
 		}
+		if errors.Is(err, pgerror.ErrInvalidText) {
+			code = ErrCodeInvalidText
+		}
 		return c.failExtended(code, err)
 	}
 	c.portals[portalName] = &portal{query: query}
@@ -690,6 +709,21 @@ func (c *Connection) handleExecute(msg *Message) error {
 		if errors.Is(err, storage.ErrSerialization) {
 			code = ErrCodeSerializationFailure
 			c.txStatus = TxStatusIdle
+		} else if errors.Is(err, pgerror.ErrInvalidText) {
+			code = ErrCodeInvalidText
+			if c.txStatus == TxStatusInBlock {
+				c.txStatus = TxStatusFailed
+			}
+		} else if errors.Is(err, pgerror.ErrDatatypeMismatch) {
+			code = ErrCodeDatatypeMismatch
+			if c.txStatus == TxStatusInBlock {
+				c.txStatus = TxStatusFailed
+			}
+		} else if analysisTypeMismatch(err) {
+			code = ErrCodeDatatypeMismatch
+			if c.txStatus == TxStatusInBlock {
+				c.txStatus = TxStatusFailed
+			}
 		} else if errors.As(err, &notNullErr) {
 			code = "23502"
 			if _, commit := stmt.(*parser.CommitStmt); commit {
@@ -1002,6 +1036,13 @@ func (c *Connection) handleProxyError(msg *Message) error {
 	return c.sendReadyForQuery()
 }
 
+// analysisTypeMismatch reports whether the error is an analyzer type-mismatch,
+// which is reported with SQLSTATE 42804 instead of a generic internal error.
+func analysisTypeMismatch(err error) bool {
+	var analysisErr *analyzer.AnalysisError
+	return errors.As(err, &analysisErr) && analysisErr.Type == analyzer.ErrTypeMismatch
+}
+
 type boundParameter struct {
 	value  []byte
 	oid    int32
@@ -1092,11 +1133,10 @@ func bindQuery(query string, params []boundParameter) (string, error) {
 			i += end + 4
 			continue
 		}
-		if ch == '\'' || ch == '"' || ch == '`' || ch == '[' {
+		// Only string literals and quoted identifiers are skipped. Brackets are
+		// not a quoted region: a subscript such as tags[$1] must bind $1.
+		if ch == '\'' || ch == '"' || ch == '`' {
 			quote = ch
-			if ch == '[' {
-				quote = ']'
-			}
 			result.WriteByte(ch)
 			i++
 			continue
@@ -1129,6 +1169,9 @@ func parameterLiteral(param boundParameter) (string, error) {
 		return "NULL", nil
 	}
 	if param.format == 1 {
+		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)
+		}
 		switch param.oid {
 		case 16:
 			if len(param.value) != 1 {
@@ -1158,6 +1201,17 @@ func parameterLiteral(param boundParameter) (string, error) {
 		}
 	}
 	value := string(param.value)
+	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
+		// evaluation, then emit a typed literal the executor accepts unchanged.
+		arrayType := element + "[]"
+		canonical, err := executor.CanonicalizeArrayText(value, arrayType)
+		if err != nil {
+			return "", err
+		}
+		return "'" + strings.ReplaceAll(canonical, "'", "''") + "'::" + arrayType, nil
+	}
 	switch param.oid {
 	case 0:
 		if strings.EqualFold(value, "true") || strings.EqualFold(value, "false") {
@@ -1447,6 +1501,72 @@ func (c *Connection) writeMessage(msgType byte, data []byte) error {
 	return WriteMessage(c.writer, msgType, data)
 }
 
+// arrayElementTypeName reports the element type of an array type name such as
+// "INTEGER[]". It reports false for scalar type names.
+func arrayElementTypeName(typeName string) (string, bool) {
+	upper := strings.ToUpper(strings.TrimSpace(typeName))
+	if !strings.HasSuffix(upper, "[]") {
+		return "", false
+	}
+	return strings.TrimSpace(strings.TrimSuffix(upper, "[]")), true
+}
+
+// arrayOIDForElement maps an array element type name to its PostgreSQL array
+// OID. Narrower integer types report int8[] because values are not
+// range-checked, matching the scalar carrier rule; NUMERIC reports numeric[]
+// because array elements stay text.
+func arrayOIDForElement(element string) int32 {
+	base := strings.ToUpper(strings.TrimSpace(element))
+	if idx := strings.IndexByte(base, '('); idx >= 0 {
+		base = strings.TrimSpace(base[:idx])
+	}
+	switch base {
+	case "INTEGER", "INT", "INT4":
+		return 1007 // INT4ARRAYOID
+	case "BIGINT", "INT8", "SMALLINT", "INT2", "TINYINT", "MEDIUMINT":
+		return 1016 // INT8ARRAYOID
+	case "BOOLEAN", "BOOL":
+		return 1000 // BOOLARRAYOID
+	case "REAL", "FLOAT", "FLOAT4":
+		return 1021 // FLOAT4ARRAYOID
+	case "DOUBLE", "DOUBLE PRECISION", "FLOAT8":
+		return 1022 // FLOAT8ARRAYOID
+	case "NUMERIC", "DECIMAL":
+		return 1231 // NUMERICARRAYOID
+	case "VARCHAR", "NVARCHAR", "CHARACTER VARYING":
+		return 1015 // VARCHARARRAYOID
+	default:
+		return 1009 // TEXTARRAYOID
+	}
+}
+
+// arrayParamElementType maps an array parameter OID to the element type the
+// bound literal is cast to. Unknown OIDs are handled by callers.
+func arrayParamElementType(oid int32) (string, bool) {
+	switch oid {
+	case 1009:
+		return "TEXT", true
+	case 1015:
+		return "VARCHAR", true
+	case 1007:
+		return "INTEGER", true
+	case 1016:
+		return "BIGINT", true
+	case 1005:
+		return "SMALLINT", true
+	case 1000:
+		return "BOOLEAN", true
+	case 1021:
+		return "REAL", true
+	case 1022:
+		return "DOUBLE", true
+	case 1231:
+		return "NUMERIC", true
+	default:
+		return "", false
+	}
+}
+
 // getOIDForType returns PostgreSQL OID for type.
 //
 // Types narrower than the value the engine carries are reported as the carrier
@@ -1455,6 +1575,9 @@ func (c *Connection) writeMessage(msgType byte, data []byte) error {
 // than having been rejected on write. INTEGER and REAL keep their declared
 // mappings, which predate this rule and are what existing clients expect.
 func (c *Connection) getOIDForType(typeName string) int32 {
+	if element, ok := arrayElementTypeName(typeName); ok {
+		return arrayOIDForElement(element)
+	}
 	switch strings.ToUpper(typeName) {
 	case "SMALLINT", "INT2", "TINYINT", "MEDIUMINT":
 		// Carried as int64 and not range-checked, so int8 is the honest type.
@@ -1492,6 +1615,9 @@ func (c *Connection) getOIDForType(typeName string) int32 {
 
 // getTypeSizeForType returns type size in bytes, or -1 for variable length.
 func (c *Connection) getTypeSizeForType(typeName string) int16 {
+	if _, ok := arrayElementTypeName(typeName); ok {
+		return -1 // arrays are variable length
+	}
 	switch strings.ToUpper(typeName) {
 	case "SMALLINT", "INT2", "TINYINT", "MEDIUMINT":
 		return 8 // Reported as int8; see getOIDForType.

+ 28 - 0
pkg/pgserver/oid_test.go

@@ -428,3 +428,31 @@ func TestEmptyResultWildcardMatchesNames(t *testing.T) {
 		})
 	}
 }
+
+func TestGetOIDForTypeArrays(t *testing.T) {
+	c := &Connection{}
+	cases := map[string]int32{
+		"TEXT[]":             1009,
+		"VARCHAR[]":          1015,
+		"VARCHAR(10)[]":      1015,
+		"CHAR[]":             1009,
+		"INTEGER[]":          1007,
+		"INT[]":              1007,
+		"BIGINT[]":           1016,
+		"SMALLINT[]":         1016,
+		"BOOLEAN[]":          1000,
+		"BOOL[]":             1000,
+		"REAL[]":             1021,
+		"DOUBLE[]":           1022,
+		"DOUBLE PRECISION[]": 1022,
+		"NUMERIC[]":          1231,
+	}
+	for name, want := range cases {
+		if got := c.getOIDForType(name); got != want {
+			t.Errorf("getOIDForType(%q) = %d, want %d", name, got, want)
+		}
+		if size := c.getTypeSizeForType(name); size != -1 {
+			t.Errorf("getTypeSizeForType(%q) = %d, want -1", name, size)
+		}
+	}
+}

+ 2 - 0
pkg/pgserver/protocol.go

@@ -89,6 +89,8 @@ const (
 	ErrCodeDuplicateTable       = "42P07"
 	ErrCodeDuplicateColumn      = "42701"
 	ErrCodeInvalidParameter     = "22023"
+	ErrCodeInvalidText          = "22P02"
+	ErrCodeDatatypeMismatch     = "42804"
 	ErrCodeInternalError        = "XX000"
 	ErrCodeConnectionFailure    = "08006"
 	ErrCodeProtocolViolation    = "08P01"

+ 2 - 1
pkg/pgserver/sql_matching_test.go

@@ -10,7 +10,8 @@ func TestBindQuerySkipsCommentsAndQuotedRegions(t *testing.T) {
 		{"SELECT $1 /* ' $99 */ + $2", "SELECT 1 /* ' $99 */ + 2"},
 		{"SELECT $1 -- '$99\n + $2", "SELECT 1 -- '$99\n + 2"},
 		{"SELECT $1 -- $99", "SELECT 1 -- $99"},
-		{"SELECT '$99''$88', \"$77\", `$66`, [$55], $1", "SELECT '$99''$88', \"$77\", `$66`, [$55], 1"},
+		// Brackets are not a quoted region: a subscript such as tags[$1] binds.
+		{"SELECT '$99''$88', \"$77\", `$66`, tags[$1]", "SELECT '$99''$88', \"$77\", `$66`, tags[1]"},
 		{"SELECT '$1 /* no comment */', $2", "SELECT '$1 /* no comment */', 2"},
 	} {
 		got, err := bindQuery(tc.query, []boundParameter{{value: []byte("1")}, {value: []byte("2")}})

+ 52 - 0
pkg/storage/rowcodec_test.go

@@ -419,3 +419,55 @@ func BenchmarkRowCodec(b *testing.B) {
 		}
 	})
 }
+
+// Array column values are canonical text, so the existing string encoding
+// carries them without a format change. NULL and the empty array stay distinct
+// through a reload, and identical arrays encode to identical bytes.
+func TestArrayTextRowsRoundTripWithoutFormatChange(t *testing.T) {
+	row := Row{"id": int64(1), "tags": "{a,b}", "nums": "{}"}
+	encoded, err := encodeRow(row)
+	if err != nil {
+		t.Fatalf("encodeRow: %v", err)
+	}
+	if !bytes.HasPrefix(encoded, []byte(rowMagic)) || encoded[len(rowMagic)] != rowVersion {
+		t.Fatal("array row should use the current binary encoding version")
+	}
+	decoded, err := decodeRow(encoded)
+	if err != nil {
+		t.Fatalf("decodeRow: %v", err)
+	}
+	if decoded["tags"] != "{a,b}" {
+		t.Fatalf("tags = %#v, want {a,b}", decoded["tags"])
+	}
+	if decoded["nums"] != "{}" {
+		t.Fatalf("empty array = %#v, want {}", decoded["nums"])
+	}
+
+	nullRow := Row{"v": nil}
+	emptyRow := Row{"v": "{}"}
+	encodedNull, err := encodeRow(nullRow)
+	if err != nil {
+		t.Fatalf("encodeRow NULL: %v", err)
+	}
+	encodedEmpty, err := encodeRow(emptyRow)
+	if err != nil {
+		t.Fatalf("encodeRow empty array: %v", err)
+	}
+	if bytes.Equal(encodedNull, encodedEmpty) {
+		t.Fatal("NULL and empty arrays must not share an encoding")
+	}
+	decodedNull, err := decodeRow(encodedNull)
+	if err != nil {
+		t.Fatalf("decodeRow NULL: %v", err)
+	}
+	if decodedNull["v"] != nil {
+		t.Fatalf("NULL array = %#v, want nil", decodedNull["v"])
+	}
+	decodedEmpty, err := decodeRow(encodedEmpty)
+	if err != nil {
+		t.Fatalf("decodeRow empty array: %v", err)
+	}
+	if decodedEmpty["v"] != "{}" {
+		t.Fatalf("empty array = %#v, want {}", decodedEmpty["v"])
+	}
+}

+ 1 - 0
pkg/storage/schema.go

@@ -710,6 +710,7 @@ func (s *Schema) ToAnalyzerTableInfo() *analyzer.TableInfo {
 			PrimaryKey: col.PrimaryKey,
 			TableName:  s.Name,
 			Generated:  col.GeneratedExpr != "",
+			Array:      strings.HasSuffix(strings.ToUpper(strings.TrimSpace(col.Type)), "[]"),
 		})
 	}