Bläddra i källkod

add array types, ANY/ALL predicates, and array parameters

Tier C #138. PostgreSQL clients can now declare array columns, bind array
parameters, and use `expr = ANY($1)` without rewriting the query.

Design
- Encoding: an array value is its canonical PostgreSQL array-literal text
  (`{a,b}`, `{"a b",NULL}`), stored as an ordinary string. The row codec is
  unchanged (no format version bump), NULL stays nil and the empty array stays
  `{}`, and every write path canonicalizes before storage so equal arrays encode
  to identical bytes. Elements are canonicalized per declared element type
  (integers in decimal, reals in shortest round-trip form, booleans as t/f).
- Types: TEXT[], VARCHAR(n)[], INTEGER[], BIGINT[], SMALLINT[], BOOLEAN[],
  REAL[], DOUBLE PRECISION[], and NUMERIC[] in CREATE TABLE and ALTER TABLE ADD
  COLUMN. The catalog stores `NAME[]`, so sqlite_master, PRAGMA table_info, and
  composite primary keys keep working unchanged.
- Values: `'{...}'` literals, `ARRAY[...]`, `::type[]` and CAST(x AS type[]),
  1-based `arr[n]`, `array_length(arr, 1)`, and `unnest(array)` as a single FROM
  table function. Scalar unnest is rejected with 0A000.
- Predicates: `expr op ANY(array)` and `expr op ALL(array)` with SQL
  three-valued logic; NULL elements make a non-matching ANY/ALL NULL, ANY over
  an empty array is false, and ALL over an empty array is true.
- Parameters: text-format array parameters are validated and canonicalized at
  Bind and bound as a typed array literal. Array OIDs 1009 text[], 1015
  varchar[], 1007 int4[], 1016 int8[], 1005 int2[], 1000 bool[], 1021 float4[],
  1022 float8[], and 1231 numeric[]. Binary-format array parameters are
  rejected with 0A000 instead of being misread.
- Metadata: direct array projections report the array OID and -1 type size.
  ARRAY[] constructor and cast results report the element's carrier array type
  (SMALLINT[] -> int8[], NUMERIC[] -> float8[]), matching the scalar rules.
- Rejected with 0A000: multidimensional arrays (`[][]`, `ARRAY[ARRAY[..]]`,
  nested `{{..},{..}}` text), arrays of unsupported element types (BLOB, DATE,
  ...), and ANY/ALL over a subquery. Malformed array input reports 22P02.

Tests cover the parser (types, casts, subscripts, quantifiers, rejections), the
analyzer, the executor (canonicalization, NULL/empty arrays, 3VL, unnest,
defaults, ALTER, composite PK, reload), storage row encoding, and the pgserver
simple and extended protocol paths including array OIDs and error codes. The
capabilities document now advertises array types and parameters, and the compat
suite checks array DDL, ANY/ALL, and the 0A000 rejections.
Danilo Fragoso 1 dag sedan
förälder
incheckning
63101a6b58

BIN
bin/pizzasql


+ 88 - 0
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
 // defineTableRef adds a single FROM/JOIN table to scope. The reference may be a
 // derived table (subquery), a CTE, or a real catalog table.
 // derived table (subquery), a CTE, or a real catalog table.
 func (a *Analyzer) defineTableRef(ref parser.TableRef) error {
 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 ref.Subquery != nil {
 		if err := a.analyzeSelect(ref.Subquery); err != nil {
 		if err := a.analyzeSelect(ref.Subquery); err != nil {
 			return err
 			return err
@@ -844,6 +865,12 @@ func (a *Analyzer) analyzeExpr(expr parser.Expr) (*ExprInfo, error) {
 		}, nil
 		}, nil
 	case *parser.ExistsExpr:
 	case *parser.ExistsExpr:
 		return a.analyzeExistsExpr(e)
 		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:
 	case *parser.SubqueryExpr:
 		return a.analyzeSubqueryExpr(e)
 		return a.analyzeSubqueryExpr(e)
 	case *parser.WindowExpr:
 	case *parser.WindowExpr:
@@ -853,6 +880,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}
+	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
 // analyzeWindowExpr analyzes a window function. Window functions are not
 // aggregates for the purpose of GROUP BY handling.
 // aggregates for the purpose of GROUP BY handling.
 func (a *Analyzer) analyzeWindowExpr(e *parser.WindowExpr) (*ExprInfo, error) {
 func (a *Analyzer) analyzeWindowExpr(e *parser.WindowExpr) (*ExprInfo, error) {

+ 96 - 0
pkg/analyzer/arrays_test.go

@@ -0,0 +1,96 @@
+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},
+			{Name: "nums", Type: TypeFromName("INTEGER[]"), Nullable: 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`,
+	} {
+		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"},
+	}
+	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)
+	}
+}

+ 8 - 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.
 // TypeFromName returns the Type for a SQL type name using SQLite affinity rules.
 // See: https://www.sqlite.org/datatype3.html
 // See: https://www.sqlite.org/datatype3.html
 func TypeFromName(name string) Type {
 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
 	// Rule 1: If the type contains "INT" -> INTEGER
 	if strings.Contains(upper, "INT") {
 	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},
 	"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},
 	"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
 	// Type functions
 	"TYPEOF": {Name: "TYPEOF", MinArgs: 1, MaxArgs: 1, ArgTypes: []Type{TypeAny}, ReturnType: TypeText, IsAggregate: false},
 	"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},
 	"CAST":   {Name: "CAST", MinArgs: 1, MaxArgs: 1, ArgTypes: []Type{TypeAny}, ReturnType: TypeAny, IsAggregate: false},

+ 3 - 3
pkg/capabilities/capabilities.go

@@ -205,14 +205,14 @@ func Current() *Document {
 			DateTime: true,
 			DateTime: true,
 			JSONText: true,
 			JSONText: true,
 			JSONB:    Feature{Supported: false, Status: "type_name_alias", Notes: "JSONB is accepted as a column type name with no native semantics; values are stored as text and reported as TEXT over the wire"},
 			JSONB:    Feature{Supported: 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"},
 			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"},
 			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{
 		Parameters: Parameters{
 			Positional:    true,
 			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,
 			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)
 		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)
+	}
+}

+ 563 - 0
pkg/executor/arrays.go

@@ -0,0 +1,563 @@
+package executor
+
+import (
+	"fmt"
+	"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
+	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", "NUMERIC", "DECIMAL":
+		return arrayElementReal, 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 !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 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:
+		return strconv.ParseInt(raw, 10, 64)
+	case arrayElementReal:
+		return strconv.ParseFloat(raw, 64)
+	case arrayElementBoolean:
+		value, _ := parseArrayBoolean(raw)
+		return value, nil
+	default:
+		return raw, 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
+}
+
+// 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.
+func (e *Executor) evalSubscriptExpr(expr *parser.SubscriptExpr, row storage.Row) (interface{}, error) {
+	value, err := 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 subscriptArrayValue(value, index)
+}
+
+// subscriptArrayValue applies a 1-based subscript to an evaluated array value.
+func subscriptArrayValue(value, index interface{}) (interface{}, error) {
+	if value == nil || index == nil {
+		return nil, nil
+	}
+	elements, err := parseArrayLiteral(toString(value))
+	if err != nil {
+		return nil, fmt.Errorf("array subscript requires an array value: %w", err)
+	}
+	position := toInt64(index)
+	if position < 1 || position > int64(len(elements)) {
+		return nil, nil
+	}
+	element := elements[position-1]
+	if element == nil {
+		return nil, nil
+	}
+	return *element, nil
+}
+
+// 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.evalExpr(expr.Array, row)
+	if err != nil {
+		return nil, err
+	}
+	if arrayValue == nil {
+		return nil, nil
+	}
+	elements, err := parseArrayLiteral(toString(arrayValue))
+	if err != nil {
+		return nil, fmt.Errorf("ANY/ALL requires an array operand: %w", err)
+	}
+	return evalQuantifiedComparison(expr.Op, expr.All, left, elements)
+}
+
+// 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) (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)
+		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 string) (bool, error) {
+	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
+	}
+	cmp := compare(left, right)
+	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 ANY/ALL comparison operator: %v", op)
+	}
+}
+
+// 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 evalArrayLength(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
+	}
+	if toInt64(args[1]) != 1 {
+		return nil, nil
+	}
+	elements, err := parseArrayLiteral(toString(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
+}
+
+// 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
+}

+ 398 - 0
pkg/executor/arrays_test.go

@@ -0,0 +1,398 @@
+package executor
+
+import (
+	"errors"
+	"strings"
+	"testing"
+
+	"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.5,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`, "10"},
+		{`SELECT nums[3] FROM t WHERE id = 1`, "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`, "t"},
+		{`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])
+	}
+}

+ 7 - 0
pkg/executor/ddl_defaults.go

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

+ 186 - 3
pkg/executor/executor.go

@@ -384,7 +384,7 @@ func isCountStarSingleTable(stmt *parser.SelectStmt) bool {
 		return false
 		return false
 	}
 	}
 	ref := stmt.From[0]
 	ref := stmt.From[0]
-	if ref.Subquery != nil || ref.Join != nil {
+	if ref.Subquery != nil || ref.Join != nil || ref.Function != nil {
 		return false
 		return false
 	}
 	}
 	if len(stmt.Columns) != 1 || stmt.Columns[0].Star {
 	if len(stmt.Columns) != 1 || stmt.Columns[0].Star {
@@ -413,6 +413,10 @@ func (e *Executor) executeSelect(stmt *parser.SelectStmt) (*Result, error) {
 		return e.executeSelectExpr(stmt)
 		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
 	// 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
 	// every entry in a comma-separated list instead of dispatching solely on the
 	// first derived table.
 	// first derived table.
@@ -1506,6 +1510,65 @@ func (e *Executor) executeSelectExpr(stmt *parser.SelectStmt) (*Result, error) {
 	return result, nil
 	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.
 // executeSelectFromSubquery executes a SELECT with a subquery in FROM clause.
 func (e *Executor) executeSelectFromSubquery(stmt *parser.SelectStmt) (*Result, error) {
 func (e *Executor) executeSelectFromSubquery(stmt *parser.SelectStmt) (*Result, error) {
 	// Execute the subquery to get the derived table
 	// Execute the subquery to get the derived table
@@ -2743,6 +2806,9 @@ func (e *Executor) applyUpsert(table string, schema *storage.Schema, existing, r
 			updates[assignment.Column] = value
 			updates[assignment.Column] = value
 			merged[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.
 		// Recompute STORED generated columns so an upsert keeps them current.
 		if err := e.applyGeneratedColumns(schema, merged); err != nil {
 		if err := e.applyGeneratedColumns(schema, merged); err != nil {
 			return nil, err
 			return nil, err
@@ -2951,6 +3017,11 @@ func (e *Executor) executeInsert(stmt *parser.InsertStmt) (*Result, error) {
 // RETURNING. An ON CONFLICT DO UPDATE returns the merged existing row, not the
 // RETURNING. An ON CONFLICT DO UPDATE returns the merged existing row, not the
 // candidate, so RETURNING and unique-target upserts observe the real row.
 // 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) {
 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) {
 	if stmt.OnConflict == parser.ConflictIgnore || (stmt.ConflictDoNothing && len(stmt.ConflictTarget) == 0) {
 		conflict, err := e.hasAnyInsertConflict(tableName, schema, row)
 		conflict, err := e.hasAnyInsertConflict(tableName, schema, row)
 		if err != nil {
 		if err != nil {
@@ -3256,6 +3327,16 @@ func (e *Executor) primeSubqueries(expr parser.Expr) {
 			walk(n.Expr)
 			walk(n.Expr)
 		case *parser.CastExpr:
 		case *parser.CastExpr:
 			walk(n.Expr)
 			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)
 	walk(expr)
@@ -3314,6 +3395,9 @@ func (e *Executor) executeUpdate(stmt *parser.UpdateStmt) (*Result, error) {
 			updates[assign.Column] = val
 			updates[assign.Column] = val
 			merged[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 {
 		if err := e.applyGeneratedColumns(schema, merged); err != nil {
 			return nil, err
 			return nil, err
 		}
 		}
@@ -3497,6 +3581,9 @@ func (e *Executor) executeUpdateFrom(stmt *parser.UpdateStmt, schema *storage.Sc
 			}
 			}
 			row[assign.Column] = val
 			row[assign.Column] = val
 		}
 		}
+		if err := e.normalizeArrayRow(schema, row); err != nil {
+			return nil, err
+		}
 		if err := e.applyGeneratedColumns(schema, row); err != nil {
 		if err := e.applyGeneratedColumns(schema, row); err != nil {
 			return nil, err
 			return nil, err
 		}
 		}
@@ -3756,7 +3843,7 @@ func (e *Executor) executeCreateTable(stmt *parser.CreateTableStmt) (*Result, er
 	for _, colDef := range stmt.Columns {
 	for _, colDef := range stmt.Columns {
 		col := storage.Column{
 		col := storage.Column{
 			Name:     colDef.Name,
 			Name:     colDef.Name,
-			Type:     colDef.Type.Name,
+			Type:     colDef.Type.SQLTypeName(),
 			Nullable: true,
 			Nullable: true,
 		}
 		}
 
 
@@ -3792,6 +3879,10 @@ func (e *Executor) executeCreateTable(stmt *parser.CreateTableStmt) (*Result, er
 					if err != nil {
 					if err != nil {
 						return nil, err
 						return nil, err
 					}
 					}
+					val, exprText, err = canonicalArrayDefault(col.Type, val, exprText)
+					if err != nil {
+						return nil, err
+					}
 					col.Default = val
 					col.Default = val
 					col.DefaultExpr = exprText
 					col.DefaultExpr = exprText
 				}
 				}
@@ -4280,7 +4371,7 @@ func (e *Executor) executeAlterTableAddColumn(table string, action *parser.AddCo
 
 
 	col := storage.Column{
 	col := storage.Column{
 		Name:     action.Column.Name,
 		Name:     action.Column.Name,
-		Type:     action.Column.Type.Name,
+		Type:     action.Column.Type.SQLTypeName(),
 		Nullable: true,
 		Nullable: true,
 	}
 	}
 	var uniqueConstraint *parser.ColumnConstraint
 	var uniqueConstraint *parser.ColumnConstraint
@@ -4305,6 +4396,10 @@ func (e *Executor) executeAlterTableAddColumn(table string, action *parser.AddCo
 				if err != nil {
 				if err != nil {
 					return nil, err
 					return nil, err
 				}
 				}
+				val, exprText, err = canonicalArrayDefault(col.Type, val, exprText)
+				if err != nil {
+					return nil, err
+				}
 				col.Default = val
 				col.Default = val
 				col.DefaultExpr = exprText
 				col.DefaultExpr = exprText
 			}
 			}
@@ -5095,6 +5190,12 @@ func (e *Executor) evalExpr(expr parser.Expr, row storage.Row) (interface{}, err
 		return e.evalSubqueryExpr(ex, row)
 		return e.evalSubqueryExpr(ex, row)
 	case *parser.ExistsExpr:
 	case *parser.ExistsExpr:
 		return e.evalExistsExpr(ex, row)
 		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:
 	default:
 		return nil, fmt.Errorf("unsupported expression type: %T", expr)
 		return nil, fmt.Errorf("unsupported expression type: %T", expr)
 	}
 	}
@@ -5924,6 +6025,10 @@ func (e *Executor) evalFunctionCall(fn *parser.FunctionCall, row storage.Row) (i
 		return evalTimediffFunc(args)
 		return evalTimediffFunc(args)
 	case "PERCENT_DIFF":
 	case "PERCENT_DIFF":
 		return evalPercentDiff(args)
 		return evalPercentDiff(args)
+	case "ARRAY_LENGTH":
+		return evalArrayLength(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":
 	case "PIZZASQL_VERSION":
 		return version.String(), nil
 		return version.String(), nil
 	case "SQLITE_VERSION":
 	case "SQLITE_VERSION":
@@ -6215,6 +6320,9 @@ func (e *Executor) evalCastExpr(expr *parser.CastExpr, row storage.Row) (interfa
 	if val == nil {
 	if val == nil {
 		return nil, nil // CAST(NULL AS any) = NULL
 		return nil, nil // CAST(NULL AS any) = NULL
 	}
 	}
+	if expr.Type.Array {
+		return evalArrayCast(expr.Type, val)
+	}
 
 
 	typeName := strings.ToUpper(expr.Type.Name)
 	typeName := strings.ToUpper(expr.Type.Name)
 	switch {
 	switch {
@@ -6769,6 +6877,9 @@ func (e *Executor) evalExprWithAggregates(expr parser.Expr, rows []storage.Row)
 		if val == nil {
 		if val == nil {
 			return nil, nil
 			return nil, nil
 		}
 		}
+		if ex.Type.Array {
+			return evalArrayCast(ex.Type, val)
+		}
 		switch strings.ToUpper(ex.Type.Name) {
 		switch strings.ToUpper(ex.Type.Name) {
 		case "INTEGER", "INT", "BIGINT", "SMALLINT", "TINYINT", "SIGNED":
 		case "INTEGER", "INT", "BIGINT", "SMALLINT", "TINYINT", "SIGNED":
 			if isIntVal(val) {
 			if isIntVal(val) {
@@ -6891,6 +7002,47 @@ func (e *Executor) evalExprWithAggregates(expr parser.Expr, rows []storage.Row)
 			}
 			}
 			return compare(val, high) <= 0, nil
 			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.evalExprWithAggregates(ex.Expr, rows)
+		if err != nil {
+			return nil, err
+		}
+		index, err := e.evalExprWithAggregates(ex.Index, rows)
+		if err != nil {
+			return nil, err
+		}
+		return subscriptArrayValue(value, index)
+	case *parser.AnyAllExpr:
+		left, err := e.evalExprWithAggregates(ex.Left, rows)
+		if err != nil {
+			return nil, err
+		}
+		arrayValue, err := e.evalExprWithAggregates(ex.Array, rows)
+		if err != nil {
+			return nil, err
+		}
+		if arrayValue == nil {
+			return nil, nil
+		}
+		elements, err := parseArrayLiteral(toString(arrayValue))
+		if err != nil {
+			return nil, fmt.Errorf("ANY/ALL requires an array operand: %w", err)
+		}
+		return evalQuantifiedComparison(ex.Op, ex.All, left, elements)
 	case *parser.InExpr:
 	case *parser.InExpr:
 		left, err := e.evalExprWithAggregates(ex.Left, rows)
 		left, err := e.evalExprWithAggregates(ex.Left, rows)
 		if err != nil {
 		if err != nil {
@@ -7056,6 +7208,17 @@ func (e *Executor) isAggregate(expr parser.Expr) bool {
 		}
 		}
 	case *parser.CastExpr:
 	case *parser.CastExpr:
 		return e.isAggregate(ex.Expr)
 		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:
 	case *parser.IsNullExpr:
 		return e.isAggregate(ex.Left)
 		return e.isAggregate(ex.Left)
 	case *parser.IsDistinctExpr:
 	case *parser.IsDistinctExpr:
@@ -7648,6 +7811,16 @@ func collectColumnRefs(expr parser.Expr) []string {
 			walk(n.Expr)
 			walk(n.Expr)
 		case *parser.CastExpr:
 		case *parser.CastExpr:
 			walk(n.Expr)
 			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:
 		case *parser.SubqueryExpr:
 			// Subqueries may reference outer columns
 			// Subqueries may reference outer columns
 			hasSubquery = true
 			hasSubquery = true
@@ -7716,6 +7889,16 @@ func collectTableColumnRefs(expr parser.Expr) []tableColRef {
 			walk(n.Expr)
 			walk(n.Expr)
 		case *parser.CastExpr:
 		case *parser.CastExpr:
 			walk(n.Expr)
 			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)
 	walk(expr)

+ 35 - 0
pkg/executor/indexexpr.go

@@ -125,6 +125,23 @@ func ValidateDeterministicExpr(expr parser.Expr) error {
 		return ValidateDeterministicExpr(e.Else)
 		return ValidateDeterministicExpr(e.Else)
 	case *parser.CastExpr:
 	case *parser.CastExpr:
 		return ValidateDeterministicExpr(e.Expr)
 		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:
 	case *parser.InExpr:
 		if e.Subquery != nil {
 		if e.Subquery != nil {
 			return fmt.Errorf("subqueries are not allowed in index expressions")
 			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,
 	"REPLACE": true, "PRINTF": true, "HEX": true, "UNHEX": true,
 	"ZEROBLOB": true, "INSTR": true, "GLOB": true, "ROUND": true,
 	"ZEROBLOB": true, "INSTR": true, "GLOB": true, "ROUND": true,
 	"MAX": true, "MIN": true, "CONCAT": true, "PERCENT_DIFF": true,
 	"MAX": true, "MIN": true, "CONCAT": true, "PERCENT_DIFF": true,
+	"ARRAY_LENGTH": true,
 	// JSON1 scalar functions.
 	// JSON1 scalar functions.
 	"JSON": true, "JSONB": true, "JSON_VALID": true, "JSONB_VALID": true,
 	"JSON": true, "JSONB": true, "JSON_VALID": true, "JSONB_VALID": true,
 	"JSON_TYPE": true, "JSON_EXTRACT": true, "JSONB_EXTRACT": 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)
 		return ValidateIndexColumns(e.Else, schema)
 	case *parser.CastExpr:
 	case *parser.CastExpr:
 		return ValidateIndexColumns(e.Expr, schema)
 		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:
 	case *parser.InExpr:
 		if err := ValidateIndexColumns(e.Left, schema); err != nil {
 		if err := ValidateIndexColumns(e.Left, schema); err != nil {
 			return err
 			return err

+ 69 - 1
pkg/executor/result_types.go

@@ -149,10 +149,16 @@ func inferExprType(expr parser.Expr, resolve columnTypeResolver) string {
 	case *parser.LiteralExpr:
 	case *parser.LiteralExpr:
 		return literalType(e)
 		return literalType(e)
 	case *parser.CastExpr:
 	case *parser.CastExpr:
-		if typeName, ok := castResultType(e.Type.Name); ok {
+		if typeName, ok := castResultType(e.Type.SQLTypeName()); ok {
 			return typeName
 			return typeName
 		}
 		}
 		return "TEXT"
 		return "TEXT"
+	case *parser.ArrayExpr:
+		return arrayConstructorType(e, resolve)
+	case *parser.SubscriptExpr:
+		return subscriptResultType(e, resolve)
+	case *parser.AnyAllExpr:
+		return "BOOLEAN"
 	case *parser.FunctionCall:
 	case *parser.FunctionCall:
 		return functionResultType(e, resolve)
 		return functionResultType(e, resolve)
 	case *parser.BinaryExpr:
 	case *parser.BinaryExpr:
@@ -203,6 +209,9 @@ func literalType(lit *parser.LiteralExpr) string {
 // real targets, and it does not range-check them. A narrower advertised type
 // 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.
 // (int2/int4/float4) could otherwise fail to decode a value the engine accepted.
 func castResultType(name string) (string, bool) {
 func castResultType(name string) (string, bool) {
+	if element, isArray := arrayElementType(name); isArray {
+		return arrayResultTypeName(element), true
+	}
 	switch strings.ToUpper(strings.TrimSpace(name)) {
 	switch strings.ToUpper(strings.TrimSpace(name)) {
 	case "INTEGER", "INT", "SMALLINT", "TINYINT", "MEDIUMINT", "BIGINT":
 	case "INTEGER", "INT", "SMALLINT", "TINYINT", "MEDIUMINT", "BIGINT":
 		return "BIGINT", true
 		return "BIGINT", true
@@ -217,6 +226,63 @@ 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; narrower integer types
+// report int8[] because values are not range-checked, and NUMERIC/DECIMAL report
+// float8[] because the engine carries them as 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", "NUMERIC", "DECIMAL":
+		return "DOUBLE[]"
+	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
 // functionResultType reports the result type of a function call. Aggregate
 // result types mirror the values the executor computes: COUNT is BIGINT, SUM
 // 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
 // over integers is BIGINT, SUM over float carriers is DOUBLE, and AVG is always
@@ -231,6 +297,8 @@ func functionResultType(fn *parser.FunctionCall, resolve columnTypeResolver) str
 		return "DOUBLE"
 		return "DOUBLE"
 	case "AVG":
 	case "AVG":
 		return "DOUBLE"
 		return "DOUBLE"
+	case "ARRAY_LENGTH":
+		return "INTEGER"
 	case "MIN", "MAX":
 	case "MIN", "MAX":
 		// MIN/MAX return one of the input values unchanged.
 		// MIN/MAX return one of the input values unchanged.
 		return firstArgType(fn, resolve)
 		return firstArgType(fn, resolve)

+ 8 - 0
pkg/httpserver/handler.go

@@ -17,6 +17,7 @@ import (
 	"github.com/danfragoso/pizzasql-next/pkg/executor"
 	"github.com/danfragoso/pizzasql-next/pkg/executor"
 	"github.com/danfragoso/pizzasql-next/pkg/lexer"
 	"github.com/danfragoso/pizzasql-next/pkg/lexer"
 	"github.com/danfragoso/pizzasql-next/pkg/parser"
 	"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/sqlexport"
 	"github.com/danfragoso/pizzasql-next/pkg/sqlimport"
 	"github.com/danfragoso/pizzasql-next/pkg/sqlimport"
 	"github.com/danfragoso/pizzasql-next/pkg/sqliteimport"
 	"github.com/danfragoso/pizzasql-next/pkg/sqliteimport"
@@ -534,6 +535,13 @@ func sqlStateHTTPError(err error) *HTTPError {
 			Message: message,
 			Message: message,
 		}
 		}
 	}
 	}
+	if errors.Is(err, pgerror.ErrInvalidText) {
+		return &HTTPError{
+			Status:  http.StatusBadRequest,
+			Code:    pgerror.CodeInvalidText,
+			Message: message,
+		}
+	}
 	return &HTTPError{
 	return &HTTPError{
 		Status:  http.StatusInternalServerError,
 		Status:  http.StatusInternalServerError,
 		Code:    "EXECUTION_ERROR",
 		Code:    "EXECUTION_ERROR",

+ 52 - 1
pkg/lexer/lexer.go

@@ -14,6 +14,10 @@ type Lexer struct {
 	ch      byte // current char under examination
 	ch      byte // current char under examination
 	line    int  // current line number (1-based)
 	line    int  // current line number (1-based)
 	column  int  // current column 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.
 // New creates a new Lexer for the given input.
@@ -194,7 +198,28 @@ func (l *Lexer) NextToken() Token {
 	case '`':
 	case '`':
 		tok = l.readQuotedIdentifier('`')
 		tok = l.readQuotedIdentifier('`')
 	case '[':
 	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:
 	default:
 		if (l.ch == 'x' || l.ch == 'X') && l.peekChar() == '\'' {
 		if (l.ch == 'x' || l.ch == 'X') && l.peekChar() == '\'' {
 			tok = l.readBlob()
 			tok = l.readBlob()
@@ -209,9 +234,35 @@ func (l *Lexer) NextToken() Token {
 		}
 		}
 	}
 	}
 
 
+	if tok.Type != TokenComment {
+		l.prev = tok.Type
+	}
 	return tok
 	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.
 // skipWhitespace skips spaces, tabs, and newlines.
 func (l *Lexer) skipWhitespace() {
 func (l *Lexer) skipWhitespace() {
 	for l.ch == ' ' || l.ch == '\t' || l.ch == '\n' || l.ch == '\r' {
 	for l.ch == ' ' || l.ch == '\t' || l.ch == '\n' || l.ch == '\r' {

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

+ 8 - 0
pkg/lexer/token.go

@@ -199,6 +199,11 @@ const (
 	TokenCONFLICT
 	TokenCONFLICT
 	TokenDO
 	TokenDO
 	TokenNOTHING
 	TokenNOTHING
+
+	// PostgreSQL-style syntax
+	TokenColonColon // ::
+	TokenLBracket   // [ (only when it subscripts an expression)
+	TokenRBracket   // ]
 )
 )
 
 
 var keywords = map[string]TokenType{
 var keywords = map[string]TokenType{
@@ -422,6 +427,9 @@ var tokenNames = map[TokenType]string{
 	TokenComma:      ",",
 	TokenComma:      ",",
 	TokenSemicolon:  ";",
 	TokenSemicolon:  ";",
 	TokenDot:        ".",
 	TokenDot:        ".",
+	TokenColonColon: "::",
+	TokenLBracket:   "[",
+	TokenRBracket:   "]",
 }
 }
 
 
 func (t TokenType) String() string {
 func (t TokenType) String() string {

+ 162 - 7
pkg/parser/arrays_test.go

@@ -8,12 +8,136 @@ import (
 	"github.com/danfragoso/pizzasql-next/pkg/pgerror"
 	"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{
 	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])",
+		`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{
+		`CREATE TABLE t (a INTEGER[][])`,
+		`CREATE TABLE t (a BLOB[])`,
+		`CREATE TABLE t (a DATE[])`,
+		`SELECT ARRAY[ARRAY[1,2]]`,
 	} {
 	} {
 		t.Run(sql, func(t *testing.T) {
 		t.Run(sql, func(t *testing.T) {
 			_, err := New(lexer.New(sql)).Parse()
 			_, err := New(lexer.New(sql)).Parse()
@@ -24,10 +148,41 @@ 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) {
 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 {
 	if _, err := New(lexer.New("SELECT any FROM t")).Parse(); err != nil {
 		t.Fatalf("parse: %v", err)
 		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)
+	}
+}

+ 46 - 2
pkg/parser/ast.go

@@ -87,6 +87,9 @@ type TableRef struct {
 	Alias    string
 	Alias    string
 	Subquery *SelectStmt // for derived tables (SELECT ... FROM (SELECT ...) AS alias)
 	Subquery *SelectStmt // for derived tables (SELECT ... FROM (SELECT ...) AS alias)
 	Join     *JoinClause // for joined tables
 	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.
 // JoinClause represents a JOIN clause.
@@ -208,8 +211,19 @@ type ColumnDef struct {
 // DataType represents a SQL data type.
 // DataType represents a SQL data type.
 type DataType struct {
 type DataType struct {
 	Name      string
 	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.
 // ColumnConstraint represents a column-level constraint.
@@ -673,6 +687,36 @@ type ExistsExpr struct {
 func (e *ExistsExpr) node()     {}
 func (e *ExistsExpr) node()     {}
 func (e *ExistsExpr) exprNode() {}
 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.
 // ParenExpr represents a parenthesized expression.
 type ParenExpr struct {
 type ParenExpr struct {
 	Expr Expr
 	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, ", ") + ")"
 		return strings.ToLower(e.Name) + "(" + prefix + strings.Join(args, ", ") + ")"
 	case *CastExpr:
 	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:
 	case *CaseExpr:
 		var b strings.Builder
 		var b strings.Builder
 		b.WriteString("CASE")
 		b.WriteString("CASE")

+ 196 - 13
pkg/parser/parser.go

@@ -499,7 +499,7 @@ func (p *Parser) parseSelectColumns() ([]SelectColumn, error) {
 func (p *Parser) parseTableRefs() ([]TableRef, error) {
 func (p *Parser) parseTableRefs() ([]TableRef, error) {
 	var tables []TableRef
 	var tables []TableRef
 
 
-	table, err := p.parseTableRef()
+	table, err := p.parseTableRef(true)
 	if err != nil {
 	if err != nil {
 		return nil, err
 		return nil, err
 	}
 	}
@@ -509,7 +509,7 @@ func (p *Parser) parseTableRefs() ([]TableRef, error) {
 	for {
 	for {
 		if p.curTokenIs(lexer.TokenComma) {
 		if p.curTokenIs(lexer.TokenComma) {
 			p.nextToken()
 			p.nextToken()
-			table, err := p.parseTableRef()
+			table, err := p.parseTableRef(true)
 			if err != nil {
 			if err != nil {
 				return nil, err
 				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
 	return tables, nil
 }
 }
 
 
-func (p *Parser) parseTableRef() (*TableRef, error) {
+func (p *Parser) parseTableRef(allowFunction bool) (*TableRef, error) {
 	ref := &TableRef{}
 	ref := &TableRef{}
 
 
 	// Check for subquery (SELECT ...)
 	// Check for subquery (SELECT ...)
@@ -608,6 +617,31 @@ func (p *Parser) parseTableRef() (*TableRef, error) {
 	ref.Name = p.curToken.Literal
 	ref.Name = p.curToken.Literal
 	p.nextToken()
 	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
 	// Check for schema.table
 	if p.curTokenIs(lexer.TokenDot) {
 	if p.curTokenIs(lexer.TokenDot) {
 		p.nextToken()
 		p.nextToken()
@@ -732,7 +766,7 @@ func (p *Parser) parseJoin() (*JoinClause, error) {
 	}
 	}
 
 
 	// Parse table reference
 	// Parse table reference
-	table, err := p.parseTableRef()
+	table, err := p.parseTableRef(true)
 	if err != nil {
 	if err != nil {
 		return nil, err
 		return nil, err
 	}
 	}
@@ -845,7 +879,7 @@ func (p *Parser) parseInsert() (*InsertStmt, error) {
 	p.nextToken()
 	p.nextToken()
 
 
 	// Parse table name
 	// Parse table name
-	table, err := p.parseTableRef()
+	table, err := p.parseTableRef(false)
 	if err != nil {
 	if err != nil {
 		return nil, err
 		return nil, err
 	}
 	}
@@ -1006,7 +1040,7 @@ func (p *Parser) parseUpdate() (*UpdateStmt, error) {
 	p.nextToken() // consume UPDATE
 	p.nextToken() // consume UPDATE
 
 
 	// Parse table name
 	// Parse table name
-	table, err := p.parseTableRef()
+	table, err := p.parseTableRef(false)
 	if err != nil {
 	if err != nil {
 		return nil, err
 		return nil, err
 	}
 	}
@@ -1087,7 +1121,7 @@ func (p *Parser) parseDelete() (*DeleteStmt, error) {
 	p.nextToken()
 	p.nextToken()
 
 
 	// Parse table name
 	// Parse table name
-	table, err := p.parseTableRef()
+	table, err := p.parseTableRef(false)
 	if err != nil {
 	if err != nil {
 		return nil, err
 		return nil, err
 	}
 	}
@@ -1170,7 +1204,7 @@ func (p *Parser) parseCreateTable() (*CreateTableStmt, error) {
 	}
 	}
 
 
 	// Parse table name
 	// Parse table name
-	table, err := p.parseTableRef()
+	table, err := p.parseTableRef(false)
 	if err != nil {
 	if err != nil {
 		return nil, err
 		return nil, err
 	}
 	}
@@ -1337,6 +1371,12 @@ func (p *Parser) parseDataType() (*DataType, error) {
 		p.nextToken()
 		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
 	// Check for precision/scale
 	if p.curTokenIs(lexer.TokenLParen) {
 	if p.curTokenIs(lexer.TokenLParen) {
 		p.nextToken()
 		p.nextToken()
@@ -1362,9 +1402,41 @@ func (p *Parser) parseDataType() (*DataType, error) {
 		p.nextToken()
 		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
 	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 {
 func (p *Parser) isDataTypeKeyword() bool {
 	switch p.curToken.Type {
 	switch p.curToken.Type {
 	case lexer.TokenINTEGER, lexer.TokenINT, lexer.TokenTINYINT, lexer.TokenSMALLINT, lexer.TokenMEDIUMINT, lexer.TokenBIGINT,
 	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
 	// Parse table names
 	for {
 	for {
-		table, err := p.parseTableRef()
+		table, err := p.parseTableRef(false)
 		if err != nil {
 		if err != nil {
 			return nil, err
 			return nil, err
 		}
 		}
@@ -1955,7 +2027,7 @@ func (p *Parser) parseDropView() (*DropViewStmt, error) {
 	}
 	}
 
 
 	for {
 	for {
-		view, err := p.parseTableRef()
+		view, err := p.parseTableRef(false)
 		if err != nil {
 		if err != nil {
 			return nil, err
 			return nil, err
 		}
 		}
@@ -2515,10 +2587,27 @@ func (p *Parser) parseComparisonExpr() (Expr, error) {
 		return nil, p.curError("expected IN, BETWEEN, or LIKE after NOT")
 		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) {
 	if isComparisonOp(p.curToken.Type) {
 		op := p.curToken.Type
 		op := p.curToken.Type
 		p.nextToken()
 		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()
 		right, err := p.parseBitwiseExpr()
 		if err != nil {
 		if err != nil {
 			return nil, err
 			return nil, err
@@ -2529,6 +2618,63 @@ func (p *Parser) parseComparisonExpr() (Expr, error) {
 	return left, nil
 	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 {
+		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 {
 func isComparisonOp(t lexer.TokenType) bool {
 	switch t {
 	switch t {
 	case lexer.TokenEq, lexer.TokenNeq, lexer.TokenLt,
 	case lexer.TokenEq, lexer.TokenNeq, lexer.TokenLt,
@@ -2711,11 +2857,45 @@ func (p *Parser) parseUnaryExpr() (Expr, error) {
 	return p.parsePrimaryExpr()
 	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) {
 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 {
 	switch p.curToken.Type {
 	case lexer.TokenALL:
 	case lexer.TokenALL:
 		if p.peekTokenIs(lexer.TokenLParen) {
 		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())
 		return nil, p.curError("unexpected token in expression: " + p.curToken.Type.String())
 
 
@@ -2803,6 +2983,9 @@ func (p *Parser) parsePrimaryExpr() (Expr, error) {
 		return p.parseIdentOrFunction()
 		return p.parseIdentOrFunction()
 
 
 	case lexer.TokenIdent:
 	case lexer.TokenIdent:
+		if strings.EqualFold(p.curToken.Literal, "ARRAY") && p.peekTokenIs(lexer.TokenLBracket) {
+			return p.parseArrayConstructor()
+		}
 		return p.parseIdentOrFunction()
 		return p.parseIdentOrFunction()
 
 
 	case lexer.TokenStar:
 	case lexer.TokenStar:
@@ -2823,7 +3006,7 @@ func (p *Parser) parseIdentOrFunction() (Expr, error) {
 	// Check for function call
 	// Check for function call
 	if p.curTokenIs(lexer.TokenLParen) {
 	if p.curTokenIs(lexer.TokenLParen) {
 		if strings.EqualFold(name, "ANY") {
 		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)
 		return p.parseFunctionCall(name)
 	}
 	}

+ 12 - 0
pkg/pgerror/pgerror.go

@@ -10,6 +10,7 @@ import (
 // SQLSTATE codes used by PizzaSQL's protocol and HTTP surfaces.
 // SQLSTATE codes used by PizzaSQL's protocol and HTTP surfaces.
 const (
 const (
 	CodeFeatureNotSupported  = "0A000"
 	CodeFeatureNotSupported  = "0A000"
+	CodeInvalidText          = "22P02"
 	CodeUniqueViolation      = "23505"
 	CodeUniqueViolation      = "23505"
 	CodeForeignKeyViolation  = "23503"
 	CodeForeignKeyViolation  = "23503"
 	CodeCheckViolation       = "23514"
 	CodeCheckViolation       = "23514"
@@ -28,3 +29,14 @@ var ErrFeatureNotSupported = errors.New("feature not supported")
 func Unsupported(format string, args ...interface{}) error {
 func Unsupported(format string, args ...interface{}) error {
 	return fmt.Errorf("%w: %s", ErrFeatureNotSupported, fmt.Sprintf(format, args...))
 	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...))
+}

+ 279 - 29
pkg/pgserver/array_param_test.go

@@ -5,53 +5,303 @@ import (
 	"testing"
 	"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 := NewMessageBuilder()
 	parse.WriteString("s1")
 	parse.WriteString("s1")
-	parse.WriteString("SELECT * FROM t WHERE id = ANY($1)")
+	parse.WriteString(sql)
 	parse.WriteInt16(1)
 	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 := NewMessageBuilder()
 	bind.WriteString("p1")
 	bind.WriteString("p1")
 	bind.WriteString("s1")
 	bind.WriteString("s1")
 	bind.WriteInt16(0) // all-text parameter formats
 	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
 	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 := NewMessageBuilder()
 	execute.WriteString("p1")
 	execute.WriteString("p1")
 	execute.WriteInt32(0)
 	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 {
 		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 {
 	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()
 }
 }

+ 12 - 1
pkg/pgserver/compat_suite_test.go

@@ -135,7 +135,18 @@ 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")
 	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("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("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.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.
 	// CHECK and FOREIGN KEY are accepted and enforced.
 	expectOK("ddl.check_accepted", `CREATE TABLE checked (n INTEGER CHECK (n > 0))`)
 	expectOK("ddl.check_accepted", `CREATE TABLE checked (n INTEGER CHECK (n > 0))`)

+ 99 - 0
pkg/pgserver/connection.go

@@ -426,6 +426,11 @@ func (c *Connection) handleQuery(msg *Message) error {
 			if errors.Is(err, storage.ErrSerialization) {
 			if errors.Is(err, storage.ErrSerialization) {
 				code = ErrCodeSerializationFailure
 				code = ErrCodeSerializationFailure
 				c.txStatus = TxStatusIdle
 				c.txStatus = TxStatusIdle
+			} else if errors.Is(err, pgerror.ErrInvalidText) {
+				code = ErrCodeInvalidText
+				if c.txStatus == TxStatusInBlock {
+					c.txStatus = TxStatusFailed
+				}
 			} else if errors.As(err, &notNullErr) {
 			} else if errors.As(err, &notNullErr) {
 				code = "23502"
 				code = "23502"
 				if _, commit := stmt.(*parser.CommitStmt); commit {
 				if _, commit := stmt.(*parser.CommitStmt); commit {
@@ -585,6 +590,9 @@ func (c *Connection) handleBind(msg *Message) error {
 		if errors.Is(err, pgerror.ErrFeatureNotSupported) {
 		if errors.Is(err, pgerror.ErrFeatureNotSupported) {
 			code = ErrCodeFeatureNotSupported
 			code = ErrCodeFeatureNotSupported
 		}
 		}
+		if errors.Is(err, pgerror.ErrInvalidText) {
+			code = ErrCodeInvalidText
+		}
 		return c.failExtended(code, err)
 		return c.failExtended(code, err)
 	}
 	}
 	c.portals[portalName] = &portal{query: query}
 	c.portals[portalName] = &portal{query: query}
@@ -690,6 +698,11 @@ func (c *Connection) handleExecute(msg *Message) error {
 		if errors.Is(err, storage.ErrSerialization) {
 		if errors.Is(err, storage.ErrSerialization) {
 			code = ErrCodeSerializationFailure
 			code = ErrCodeSerializationFailure
 			c.txStatus = TxStatusIdle
 			c.txStatus = TxStatusIdle
+		} else if errors.Is(err, pgerror.ErrInvalidText) {
+			code = ErrCodeInvalidText
+			if c.txStatus == TxStatusInBlock {
+				c.txStatus = TxStatusFailed
+			}
 		} else if errors.As(err, &notNullErr) {
 		} else if errors.As(err, &notNullErr) {
 			code = "23502"
 			code = "23502"
 			if _, commit := stmt.(*parser.CommitStmt); commit {
 			if _, commit := stmt.(*parser.CommitStmt); commit {
@@ -1129,6 +1142,9 @@ func parameterLiteral(param boundParameter) (string, error) {
 		return "NULL", nil
 		return "NULL", nil
 	}
 	}
 	if param.format == 1 {
 	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 {
 		switch param.oid {
 		case 16:
 		case 16:
 			if len(param.value) != 1 {
 			if len(param.value) != 1 {
@@ -1158,6 +1174,17 @@ func parameterLiteral(param boundParameter) (string, error) {
 		}
 		}
 	}
 	}
 	value := string(param.value)
 	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 {
 	switch param.oid {
 	case 0:
 	case 0:
 		if strings.EqualFold(value, "true") || strings.EqualFold(value, "false") {
 		if strings.EqualFold(value, "true") || strings.EqualFold(value, "false") {
@@ -1447,6 +1474,72 @@ func (c *Connection) writeMessage(msgType byte, data []byte) error {
 	return WriteMessage(c.writer, msgType, data)
 	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.
 // getOIDForType returns PostgreSQL OID for type.
 //
 //
 // Types narrower than the value the engine carries are reported as the carrier
 // Types narrower than the value the engine carries are reported as the carrier
@@ -1455,6 +1548,9 @@ func (c *Connection) writeMessage(msgType byte, data []byte) error {
 // than having been rejected on write. INTEGER and REAL keep their declared
 // than having been rejected on write. INTEGER and REAL keep their declared
 // mappings, which predate this rule and are what existing clients expect.
 // mappings, which predate this rule and are what existing clients expect.
 func (c *Connection) getOIDForType(typeName string) int32 {
 func (c *Connection) getOIDForType(typeName string) int32 {
+	if element, ok := arrayElementTypeName(typeName); ok {
+		return arrayOIDForElement(element)
+	}
 	switch strings.ToUpper(typeName) {
 	switch strings.ToUpper(typeName) {
 	case "SMALLINT", "INT2", "TINYINT", "MEDIUMINT":
 	case "SMALLINT", "INT2", "TINYINT", "MEDIUMINT":
 		// Carried as int64 and not range-checked, so int8 is the honest type.
 		// Carried as int64 and not range-checked, so int8 is the honest type.
@@ -1492,6 +1588,9 @@ func (c *Connection) getOIDForType(typeName string) int32 {
 
 
 // getTypeSizeForType returns type size in bytes, or -1 for variable length.
 // getTypeSizeForType returns type size in bytes, or -1 for variable length.
 func (c *Connection) getTypeSizeForType(typeName string) int16 {
 func (c *Connection) getTypeSizeForType(typeName string) int16 {
+	if _, ok := arrayElementTypeName(typeName); ok {
+		return -1 // arrays are variable length
+	}
 	switch strings.ToUpper(typeName) {
 	switch strings.ToUpper(typeName) {
 	case "SMALLINT", "INT2", "TINYINT", "MEDIUMINT":
 	case "SMALLINT", "INT2", "TINYINT", "MEDIUMINT":
 		return 8 // Reported as int8; see getOIDForType.
 		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)
+		}
+	}
+}

+ 1 - 0
pkg/pgserver/protocol.go

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

+ 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"])
+	}
+}