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

fix array subscript typing, literal comparison, and NUMERIC precision

Review round for the array/ANY change.

Blocking:
- Array subscripts decode elements through the array expression's declared
  element type, resolved from a statement type scope captured before the scan.
  Boolean subscripts are Go bools in WHERE/NOT/=/ANY; integer and real
  subscripts keep their carriers (the HTTP payload no longer emits "10" for an
  INTEGER element); NUMERIC elements stay exact text.
- Comparisons canonicalize the non-array string operand against the array-typed
  side (binary operators, IS DISTINCT FROM, IN value lists and IN subqueries),
  and the analyzer accepts a text literal against an array column while still
  rejecting a scalar against an array.
- NUMERIC/DECIMAL elements are validated raw text with no float round-trip, so
  values above 2^53 and trailing zeros survive; casts report NUMERIC[] and the
  wire maps it to OID 1231 like the declared column.

P2:
- Subscript and array_length indices must be integers: float, boolean, and text
  report 42804 (runtime mismatch, or an analyzer type mismatch mapped to 42804)
  instead of truncating or returning NULL.
- Malformed stored BOOLEAN[] text propagates 22P02 instead of decoding to false.
- bindQuery no longer treats [...] as a quoted region, so tags[$1] binds.
- Empty unquoted array elements are rejected with 22P02.
- ARRAY[[1,2],[3,4]] reports 0A000 (multidimensional) instead of resolving a
  bogus column.
- Parsed arrays and constant array operands are memoized per statement, keyed by
  AST node and checked against the evaluated text.
- Tests document the bracket-alias reading and escaped bound array elements.

The first attempt at type resolution looked up schemas during a locked scan and
deadlocked; resolvers are now snapshots built before the scan.
Danilo Fragoso 1 день назад
Родитель
Сommit
edadd481cf

+ 35 - 5
pkg/analyzer/analyzer.go

@@ -363,11 +363,11 @@ func (a *Analyzer) derivedTableInfo(ref parser.TableRef) *TableInfo {
 			for _, tref := range collectAllTableRefs(leg.From) {
 				if t, ok := a.scope.LookupTable(tref.Name); ok {
 					for _, c := range t.Columns {
-						info.Columns = append(info.Columns, ColumnInfo{Name: c.Name, TableName: ref.Alias, Type: c.Type})
+						info.Columns = append(info.Columns, ColumnInfo{Name: c.Name, TableName: ref.Alias, Type: c.Type, Array: c.Array})
 					}
 				} else if t, ok := a.catalog.GetTable(tref.Name); ok {
 					for _, c := range t.Columns {
-						info.Columns = append(info.Columns, ColumnInfo{Name: c.Name, TableName: ref.Alias, Type: c.Type})
+						info.Columns = append(info.Columns, ColumnInfo{Name: c.Name, TableName: ref.Alias, Type: c.Type, Array: c.Array})
 					}
 				}
 			}
@@ -747,6 +747,7 @@ func (a *Analyzer) analyzeCreateTable(stmt *parser.CreateTableStmt) error {
 			Nullable:  true,
 			TableName: stmt.Table.Name,
 			Generated: colDef.GeneratedExpr != nil,
+			Array:     colDef.Type.Array,
 		}
 
 		// Process constraints
@@ -882,7 +883,7 @@ 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}
+	info := &ExprInfo{Type: TypeUnknown, Array: true}
 	for _, element := range e.Elements {
 		elementInfo, err := a.analyzeExpr(element)
 		if err != nil {
@@ -1028,6 +1029,7 @@ func (a *Analyzer) analyzeColumnRef(e *parser.ColumnRef) (*ExprInfo, error) {
 	return &ExprInfo{
 		Type:     col.Type,
 		Nullable: col.Nullable,
+		Array:    col.Array,
 	}, nil
 }
 
@@ -1077,9 +1079,12 @@ func (a *Analyzer) analyzeBinaryExpr(e *parser.BinaryExpr) (*ExprInfo, error) {
 			}
 		}
 	case lexer.TokenEq, lexer.TokenNeq, lexer.TokenLt, lexer.TokenLte, lexer.TokenGt, lexer.TokenGte:
-		// Comparison operators
+		// Comparison operators. A text literal is accepted against an array
+		// column: the runtime coerces and canonicalizes the literal to the
+		// column's array type, so `nums = '{3,4}'` compares arrays rather than
+		// INTEGER with TEXT.
 		info.Type = TypeBoolean
-		if !left.Type.IsComparable(right.Type) {
+		if !comparisonTypesCompatible(left, right) {
 			return nil, &AnalysisError{
 				Type:    ErrTypeMismatch,
 				Message: fmt.Sprintf("cannot compare %s with %s", left.Type, right.Type),
@@ -1113,6 +1118,30 @@ func (a *Analyzer) analyzeBinaryExpr(e *parser.BinaryExpr) (*ExprInfo, error) {
 	return info, nil
 }
 
+// comparisonTypesCompatible reports whether two comparison operands can be
+// compared. Arrays only compare with arrays or with text that can be read as
+// array text, so `nums = '{3,4}'` is accepted while `nums = 5` is not.
+func comparisonTypesCompatible(left, right *ExprInfo) bool {
+	if left.Array || right.Array {
+		return (left.Array && right.Array) || arrayLiteralComparison(left, right)
+	}
+	return left.Type.IsComparable(right.Type)
+}
+
+// arrayLiteralComparison reports whether one side is an array expression and the
+// other is a text expression that can be read as array text.
+func arrayLiteralComparison(left, right *ExprInfo) bool {
+	textCoercible := func(info *ExprInfo) bool {
+		switch info.Type {
+		case TypeText, TypeUnknown, TypeNull, TypeAny:
+			return true
+		default:
+			return false
+		}
+	}
+	return (left.Array && textCoercible(right)) || (right.Array && textCoercible(left))
+}
+
 func (a *Analyzer) analyzeUnaryExpr(e *parser.UnaryExpr) (*ExprInfo, error) {
 	operand, err := a.analyzeExpr(e.Operand)
 	if err != nil {
@@ -1288,6 +1317,7 @@ func (a *Analyzer) analyzeCastExpr(e *parser.CastExpr) (*ExprInfo, error) {
 		IsAggregate: exprInfo.IsAggregate,
 		IsConstant:  exprInfo.IsConstant,
 		Nullable:    exprInfo.Nullable,
+		Array:       e.Type.Array,
 	}, nil
 }
 

+ 10 - 2
pkg/analyzer/arrays_test.go

@@ -15,8 +15,8 @@ func arrayCatalog() *Catalog {
 		Name: "documents",
 		Columns: []ColumnInfo{
 			{Name: "id", Type: TypeInteger, PrimaryKey: true},
-			{Name: "tags", Type: TypeFromName("TEXT[]"), Nullable: true},
-			{Name: "nums", Type: TypeFromName("INTEGER[]"), Nullable: true},
+			{Name: "tags", Type: TypeFromName("TEXT[]"), Nullable: true, Array: true},
+			{Name: "nums", Type: TypeFromName("INTEGER[]"), Nullable: true, Array: true},
 		},
 	})
 	return catalog
@@ -59,6 +59,9 @@ func TestArrayExpressionsAnalyze(t *testing.T) {
 		`SELECT array_length(tags, 1) FROM documents`,
 		`SELECT ARRAY['a','b'] FROM documents`,
 		`SELECT '{1,2}'::INTEGER[] FROM documents`,
+		`SELECT nums FROM documents WHERE nums = '{3,4}'`,
+		`SELECT nums FROM documents WHERE '{3,4}' = nums`,
+		`SELECT nums FROM documents WHERE nums <> '{3,4}'`,
 	} {
 		if err := a.Analyze(parse(t, sql)); err != nil {
 			t.Errorf("analyze %q: %v", sql, err)
@@ -74,6 +77,11 @@ func TestArrayExpressionRejections(t *testing.T) {
 	}{
 		{`SELECT missing[1] FROM documents`, "column not found"},
 		{`SELECT tags['x'] FROM documents`, "array subscript must be an integer expression"},
+		// A text literal is only coercible to an array side, not to a scalar
+		// integer column.
+		{`SELECT id FROM documents WHERE id = '{3,4}'`, "cannot compare INTEGER with TEXT"},
+		// An array column still does not compare with a scalar number.
+		{`SELECT nums FROM documents WHERE nums = 5`, "cannot compare INTEGER with INTEGER"},
 	}
 	for _, tc := range cases {
 		err := a.Analyze(parse(t, tc.sql))

+ 5 - 0
pkg/analyzer/types.go

@@ -323,6 +323,9 @@ type ColumnInfo struct {
 	Default    interface{}
 	TableName  string // For qualified references
 	Generated  bool   // generated columns cannot be written by the user
+	// Array marks a one-dimensional array column. Type holds the element's
+	// affinity, so comparisons need this flag to accept a text array literal.
+	Array bool
 }
 
 // TableInfo describes a table schema.
@@ -354,4 +357,6 @@ type ExprInfo struct {
 	IsAggregate bool
 	IsConstant  bool
 	Nullable    bool
+	// Array marks an expression whose value is an array.
+	Array bool
 }

+ 1 - 1
pkg/capabilities/capabilities.go

@@ -205,7 +205,7 @@ func Current() *Document {
 			DateTime: true,
 			JSONText: true,
 			JSONB:    Feature{Supported: false, Status: "type_name_alias", Notes: "JSONB is accepted as a column type name with no native semantics; values are stored as text and reported as TEXT over the wire"},
-			Arrays:   Feature{Supported: 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"},
+			Arrays:   Feature{Supported: true, Status: "one_dimensional", SQLState: "0A000", Notes: "column types TEXT[], VARCHAR(n)[], INTEGER[], BIGINT[], SMALLINT[], BOOLEAN[], REAL[], DOUBLE PRECISION[], and NUMERIC[] in CREATE TABLE and ALTER TABLE ADD COLUMN; values are PostgreSQL array text ({a,b},NULL) stored canonically, with NULL and the empty array distinct; literals, ARRAY[...], ::type[] and CAST(x AS type[]), 1-based arr[n], array_length(arr, 1), and unnest(array) as the single FROM table function are supported; multidimensional arrays, arrays of other element types, and ANY/ALL over subqueries are rejected with 0A000; malformed array input reports 22P02 and a non-integer subscript or array_length dimension reports 42804; NUMERIC[] and DECIMAL[] elements are validated but stored as their exact text, so digits above 2^53 and trailing zeros survive"},
 			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"},
 		},
 

+ 206 - 16
pkg/executor/arrays.go

@@ -29,6 +29,7 @@ const (
 	arrayElementText arrayElementKind = iota
 	arrayElementInteger
 	arrayElementReal
+	arrayElementNumeric
 	arrayElementBoolean
 )
 
@@ -58,8 +59,12 @@ func arrayElementKindForName(name string) (arrayElementKind, error) {
 		return arrayElementText, nil
 	case "INTEGER", "INT", "TINYINT", "SMALLINT", "MEDIUMINT", "BIGINT":
 		return arrayElementInteger, nil
-	case "REAL", "FLOAT", "DOUBLE", "NUMERIC", "DECIMAL":
+	case "REAL", "FLOAT", "DOUBLE":
 		return arrayElementReal, nil
+	case "NUMERIC", "DECIMAL":
+		// NUMERIC elements keep their exact text: the engine's float64 carrier
+		// would lose digits above 2^53 and trailing zeros.
+		return arrayElementNumeric, nil
 	case "BOOLEAN", "BOOL":
 		return arrayElementBoolean, nil
 	default:
@@ -132,6 +137,9 @@ func parseArrayLiteral(text string) ([]*string, error) {
 				i++
 			}
 			raw := strings.TrimSpace(inner[start:i])
+			if raw == "" {
+				return nil, invalidArrayInput(text, "empty array element")
+			}
 			if !strings.EqualFold(raw, "NULL") {
 				value = &raw
 			}
@@ -256,6 +264,12 @@ func canonicalArrayElement(element *string, kind arrayElementKind, typeName stri
 		}
 		canonical := strconv.FormatFloat(f, 'g', -1, 64)
 		return &canonical, nil
+	case arrayElementNumeric:
+		if !isNumericArrayElement(raw) {
+			return nil, pgerror.InvalidText("invalid input syntax for type %s: %q", typeName, raw)
+		}
+		canonical := raw
+		return &canonical, nil
 	case arrayElementBoolean:
 		value, ok := parseArrayBoolean(raw)
 		if !ok {
@@ -298,17 +312,45 @@ func arrayElementValue(element *string, elementType string) (interface{}, error)
 	raw := *element
 	switch kind {
 	case arrayElementInteger:
-		return strconv.ParseInt(raw, 10, 64)
+		n, err := strconv.ParseInt(raw, 10, 64)
+		if err != nil {
+			return nil, pgerror.InvalidText("invalid input syntax for type integer: %q", raw)
+		}
+		return n, nil
 	case arrayElementReal:
-		return strconv.ParseFloat(raw, 64)
+		f, err := strconv.ParseFloat(raw, 64)
+		if err != nil {
+			return nil, pgerror.InvalidText("invalid input syntax for type double precision: %q", raw)
+		}
+		return f, nil
+	case arrayElementNumeric:
+		if !isNumericArrayElement(raw) {
+			return nil, pgerror.InvalidText("invalid input syntax for type numeric: %q", raw)
+		}
+		// Return the text so a value above 2^53 keeps every digit.
+		return raw, nil
 	case arrayElementBoolean:
-		value, _ := parseArrayBoolean(raw)
+		value, ok := parseArrayBoolean(raw)
+		if !ok {
+			return nil, pgerror.InvalidText("invalid input syntax for type boolean: %q", raw)
+		}
 		return value, nil
 	default:
 		return raw, nil
 	}
 }
 
+// isNumericArrayElement reports whether text is a valid SQL numeric spelling.
+// NUMERIC elements are not rewritten, so this is validation only.
+func isNumericArrayElement(raw string) bool {
+	text := strings.TrimSpace(raw)
+	if text == "" {
+		return false
+	}
+	_, err := strconv.ParseFloat(text, 64)
+	return err == 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) {
@@ -356,11 +398,99 @@ func (e *Executor) evalArrayExpr(expr *parser.ArrayExpr, row storage.Row) (inter
 	return formatArrayLiteral(elements), nil
 }
 
+// arrayExpressionIsConstant reports whether evaluating an array expression once
+// per statement is safe: it references no columns and no volatile function.
+func arrayExpressionIsConstant(expr parser.Expr) bool {
+	if expr == nil || len(collectColumnRefs(expr)) != 0 {
+		return false
+	}
+	return ValidateDeterministicExpr(expr) == nil
+}
+
+// arrayOperand evaluates an array-valued expression through the per-statement
+// value cache. A constant operand is evaluated once for the whole scan instead
+// of once per row; a column-dependent operand is re-evaluated every time.
+func (e *Executor) arrayOperand(expr parser.Expr, evaluate func() (interface{}, error)) (interface{}, error) {
+	if e.arrayValueCache != nil && expr != nil {
+		if value, ok := e.arrayValueCache[expr]; ok {
+			return value, nil
+		}
+	}
+	value, err := evaluate()
+	if err != nil {
+		return nil, err
+	}
+	if e.arrayValueCache != nil && expr != nil && arrayExpressionIsConstant(expr) {
+		e.arrayValueCache[expr] = value
+	}
+	return value, nil
+}
+
+// arrayParseEntry memoizes the parsed elements of one array expression for the
+// current statement. The evaluated text is compared before a hit is used, so a
+// value that changes between rows or between executions is re-parsed.
+type arrayParseEntry struct {
+	text     string
+	elements []*string
+	err      error
+}
+
+// parsedArray parses an evaluated array value for one expression. The
+// per-statement cache keyed by AST node keeps `= ANY($1)` to one parse for the
+// whole scan instead of one parse per row.
+func (e *Executor) parsedArray(expr parser.Expr, value interface{}) ([]*string, error) {
+	text := toString(value)
+	if e.arrayCache != nil && expr != nil {
+		if entry, ok := e.arrayCache[expr]; ok && entry.text == text {
+			return entry.elements, entry.err
+		}
+	}
+	elements, err := parseArrayLiteral(text)
+	if e.arrayCache != nil && expr != nil {
+		e.arrayCache[expr] = &arrayParseEntry{text: text, elements: elements, err: err}
+	}
+	return elements, err
+}
+
+// arrayElementTypeOf reports the element type of an expression whose value is an
+// array, resolved against the declared column types of the query currently
+// executing. An unknown type (a derived table, a bare literal) reports false so
+// the caller keeps the element text.
+func (e *Executor) arrayElementTypeOf(expr parser.Expr) (string, bool) {
+	if expr == nil {
+		return "", false
+	}
+	return arrayElementType(inferExprType(expr, e.resolveColumnType))
+}
+
+// arrayIndexValue validates an array subscript or array_length dimension.
+// PostgreSQL's signatures take integers; floats, booleans, and text are a
+// datatype mismatch, not an implicit truncation.
+func arrayIndexValue(value interface{}, what string) (int64, error) {
+	switch v := value.(type) {
+	case int64:
+		return v, nil
+	case int:
+		return int64(v), nil
+	case int32:
+		return int64(v), nil
+	case int16:
+		return int64(v), nil
+	case int8:
+		return int64(v), nil
+	default:
+		return 0, pgerror.DatatypeMismatch("%s must be an integer, got %T", what, value)
+	}
+}
+
 // evalSubscriptExpr evaluates arr[index] with PostgreSQL's 1-based indexing. An
 // out-of-range subscript yields NULL, and a NULL array, index, or element yields
-// NULL.
+// NULL. The element is decoded to the array's declared element type so a
+// boolean subscript is a Go bool rather than the canonical t/f text.
 func (e *Executor) evalSubscriptExpr(expr *parser.SubscriptExpr, row storage.Row) (interface{}, error) {
-	value, err := e.evalExpr(expr.Expr, row)
+	value, err := e.arrayOperand(expr.Expr, func() (interface{}, error) {
+		return e.evalExpr(expr.Expr, row)
+	})
 	if err != nil {
 		return nil, err
 	}
@@ -368,19 +498,22 @@ func (e *Executor) evalSubscriptExpr(expr *parser.SubscriptExpr, row storage.Row
 	if err != nil {
 		return nil, err
 	}
-	return subscriptArrayValue(value, index)
+	return e.subscriptArrayValue(expr.Expr, value, index)
 }
 
 // subscriptArrayValue applies a 1-based subscript to an evaluated array value.
-func subscriptArrayValue(value, index interface{}) (interface{}, error) {
+func (e *Executor) subscriptArrayValue(arrayExpr parser.Expr, value, index interface{}) (interface{}, error) {
 	if value == nil || index == nil {
 		return nil, nil
 	}
-	elements, err := parseArrayLiteral(toString(value))
+	position, err := arrayIndexValue(index, "array subscript")
+	if err != nil {
+		return nil, err
+	}
+	elements, err := e.parsedArray(arrayExpr, value)
 	if err != nil {
 		return nil, fmt.Errorf("array subscript requires an array value: %w", err)
 	}
-	position := toInt64(index)
 	if position < 1 || position > int64(len(elements)) {
 		return nil, nil
 	}
@@ -388,7 +521,11 @@ func subscriptArrayValue(value, index interface{}) (interface{}, error) {
 	if element == nil {
 		return nil, nil
 	}
-	return *element, nil
+	elementType, ok := e.arrayElementTypeOf(arrayExpr)
+	if !ok {
+		return *element, nil
+	}
+	return arrayElementValue(element, elementType)
 }
 
 // evalAnyAllExpr evaluates `left op ANY(array)` / `left op ALL(array)` with SQL
@@ -399,14 +536,16 @@ func (e *Executor) evalAnyAllExpr(expr *parser.AnyAllExpr, row storage.Row) (int
 	if err != nil {
 		return nil, err
 	}
-	arrayValue, err := e.evalExpr(expr.Array, row)
+	arrayValue, err := e.arrayOperand(expr.Array, func() (interface{}, error) {
+		return e.evalExpr(expr.Array, row)
+	})
 	if err != nil {
 		return nil, err
 	}
 	if arrayValue == nil {
 		return nil, nil
 	}
-	elements, err := parseArrayLiteral(toString(arrayValue))
+	elements, err := e.parsedArray(expr.Array, arrayValue)
 	if err != nil {
 		return nil, fmt.Errorf("ANY/ALL requires an array operand: %w", err)
 	}
@@ -481,17 +620,21 @@ func compareArrayElement(op lexer.TokenType, left interface{}, element string) (
 // 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) {
+func (e *Executor) evalArrayLength(arrayExpr parser.Expr, args []interface{}) (interface{}, error) {
 	if len(args) != 2 {
 		return nil, fmt.Errorf("array_length requires two arguments")
 	}
 	if args[0] == nil || args[1] == nil {
 		return nil, nil
 	}
-	if toInt64(args[1]) != 1 {
+	dimension, err := arrayIndexValue(args[1], "array dimension")
+	if err != nil {
+		return nil, err
+	}
+	if dimension != 1 {
 		return nil, nil
 	}
-	elements, err := parseArrayLiteral(toString(args[0]))
+	elements, err := e.parsedArray(arrayExpr, args[0])
 	if err != nil {
 		return nil, fmt.Errorf("array_length requires an array value: %w", err)
 	}
@@ -502,6 +645,53 @@ func evalArrayLength(args []interface{}) (interface{}, error) {
 	return int64(len(elements)), nil
 }
 
+// arrayTypeOf reports the declared array type of an expression, if any.
+func (e *Executor) arrayTypeOf(expr parser.Expr) (string, bool) {
+	if expr == nil {
+		return "", false
+	}
+	typeName := inferExprType(expr, e.resolveColumnType)
+	if _, ok := arrayElementType(typeName); !ok {
+		return "", false
+	}
+	return typeName, true
+}
+
+// canonicalArrayValue canonicalizes a value against the array type of expr. It
+// is a no-op for non-array expressions, NULL, and non-string values.
+func (e *Executor) canonicalArrayValue(arrayExpr parser.Expr, value interface{}) (interface{}, error) {
+	typeName, ok := e.arrayTypeOf(arrayExpr)
+	if !ok || value == nil {
+		return value, nil
+	}
+	text, isText := value.(string)
+	if !isText {
+		return value, nil
+	}
+	return canonicalArrayText(text, typeName)
+}
+
+// canonicalizeArrayComparison canonicalizes the string operand of a comparison
+// when the other operand is array-typed, so a text literal such as '{a, b}' or
+// '{"a","b"}' compares equal to the canonical stored '{a,b}'.
+func (e *Executor) canonicalizeArrayComparison(leftExpr, rightExpr parser.Expr, left, right interface{}) (interface{}, interface{}, error) {
+	if _, ok := e.arrayTypeOf(leftExpr); ok {
+		canonical, err := e.canonicalArrayValue(leftExpr, right)
+		if err != nil {
+			return nil, nil, err
+		}
+		right = canonical
+	}
+	if _, ok := e.arrayTypeOf(rightExpr); ok {
+		canonical, err := e.canonicalArrayValue(rightExpr, left)
+		if err != nil {
+			return nil, nil, err
+		}
+		left = canonical
+	}
+	return left, right, nil
+}
+
 // evalArrayCast converts a value to an array type. The input must already be
 // array text (a literal, a parameter, or another array value); every element is
 // validated and re-encoded canonically for the target element type.

+ 326 - 4
pkg/executor/arrays_test.go

@@ -5,6 +5,9 @@ import (
 	"strings"
 	"testing"
 
+	"github.com/danfragoso/pizzasql-next/pkg/analyzer"
+	"github.com/danfragoso/pizzasql-next/pkg/lexer"
+	"github.com/danfragoso/pizzasql-next/pkg/parser"
 	"github.com/danfragoso/pizzasql-next/pkg/pgerror"
 	"github.com/danfragoso/pizzasql-next/pkg/storage"
 )
@@ -113,7 +116,7 @@ func TestArrayConstructorAndCast(t *testing.T) {
 		{`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 '{1.50,2}'::NUMERIC[]`, "{1.50,2}"},
 		{`SELECT NULL::INTEGER[]`, nil},
 	}
 	for _, tc := range cases {
@@ -140,13 +143,14 @@ func TestArraySubscriptAndLength(t *testing.T) {
 		sql  string
 		want interface{}
 	}{
-		{`SELECT nums[1] FROM t WHERE id = 1`, "10"},
-		{`SELECT nums[3] FROM t WHERE id = 1`, "30"},
+		{`SELECT nums[1] FROM t WHERE id = 1`, int64(10)},
+		{`SELECT nums[3] FROM t WHERE id = 1`, int64(30)},
 		{`SELECT nums[4] FROM t WHERE id = 1`, nil},
 		{`SELECT nums[0] FROM t WHERE id = 1`, nil},
 		{`SELECT tags[2] FROM t WHERE id = 1`, nil},
 		{`SELECT tags[1] FROM t WHERE id = 1`, "x"},
-		{`SELECT flags[1] FROM t WHERE id = 1`, "t"},
+		{`SELECT flags[1] FROM t WHERE id = 1`, true},
+		{`SELECT flags[2] FROM t WHERE id = 1`, false},
 		{`SELECT nums[1] FROM t WHERE id = 2`, nil},
 		{`SELECT array_length(nums, 1) FROM t WHERE id = 1`, int64(3)},
 		{`SELECT array_length(nums, 1) FROM t WHERE id = 2`, nil},
@@ -396,3 +400,321 @@ func TestArrayStorageReloadIsStable(t *testing.T) {
 		t.Fatalf("canonical reloaded write did not compare equal: %v", res.Rows[0][0])
 	}
 }
+
+// A boolean element must be a Go bool in every value context. Left as the
+// canonical "t"/"f" text, toBool("f") is true and an equality against TRUE is
+// false, so WHERE/NOT/=/ANY are all checked.
+func TestBooleanSubscriptPredicates(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, `CREATE TABLE t (id INTEGER PRIMARY KEY, flags BOOLEAN[], nums INTEGER[], ratios DOUBLE PRECISION[], amounts NUMERIC[])`)
+	execMust(t, e, `INSERT INTO t VALUES
+		(1, '{t,f}', '{10,20}', '{1.5,2.5}', '{12345678901234567890,1.10}'),
+		(2, '{f,t}', '{30,40}', '{3.5}', '{2.50}'),
+		(3, NULL, NULL, NULL, NULL)`)
+
+	cases := []struct {
+		sql  string
+		want interface{}
+	}{
+		{`SELECT count(*) FROM t WHERE flags[1]`, int64(1)},
+		{`SELECT count(*) FROM t WHERE NOT flags[1]`, int64(1)},
+		{`SELECT count(*) FROM t WHERE flags[1] = TRUE`, int64(1)},
+		{`SELECT count(*) FROM t WHERE flags[1] = FALSE`, int64(1)},
+		{`SELECT count(*) FROM t WHERE flags[1] = ANY('{true}')`, int64(1)},
+		{`SELECT count(*) FROM t WHERE flags[1] = ANY('{false}')`, int64(1)},
+		{`SELECT flags[1] = TRUE FROM t WHERE id = 2`, false},
+		{`SELECT flags[1] FROM t WHERE id = 2`, false},
+		// Integer and real elements keep their Go carrier, so the HTTP layer
+		// emits a JSON number and the wire metadata matches.
+		{`SELECT nums[2] FROM t WHERE id = 1`, int64(20)},
+		{`SELECT ratios[1] FROM t WHERE id = 1`, 1.5},
+		// NUMERIC keeps its exact text instead of a float round-trip.
+		{`SELECT amounts[1] FROM t WHERE id = 1`, "12345678901234567890"},
+		{`SELECT amounts[2] FROM t WHERE id = 1`, "1.10"},
+		{`SELECT count(*) FROM t WHERE amounts[1] = '12345678901234567890'`, int64(1)},
+		{`SELECT count(*) FROM t WHERE amounts[2] > 1`, int64(1)},
+	}
+	for _, tc := range cases {
+		res := execMust(t, e, tc.sql)
+		if got := res.Rows[0][0]; got != tc.want {
+			t.Errorf("%s = %#v, want %#v", tc.sql, got, tc.want)
+		}
+	}
+
+	res := execMust(t, e, `SELECT nums[1] AS n, ratios[1] AS r, flags[1] AS f, amounts[1] AS a FROM t WHERE id = 1`)
+	wantTypes := []string{"INTEGER", "DOUBLE", "BOOLEAN", "NUMERIC"}
+	for i, want := range wantTypes {
+		if res.ColumnTypes[i] != want {
+			t.Errorf("column %d type = %q, want %q", i, res.ColumnTypes[i], want)
+		}
+	}
+}
+
+// A text literal is canonicalized against the array-typed side, so equivalent
+// spellings compare equal to the stored canonical text.
+func TestArrayLiteralComparisonCanonicalization(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, `CREATE TABLE t (id INTEGER PRIMARY KEY, tags TEXT[], nums INTEGER[], amounts NUMERIC[])`)
+	execMust(t, e, `INSERT INTO t VALUES (1, '{a,b}', '{3,4}', '{1.10,2}')`)
+
+	cases := []struct {
+		sql  string
+		want interface{}
+	}{
+		{`SELECT count(*) FROM t WHERE tags = '{a, b}'`, int64(1)},
+		{`SELECT count(*) FROM t WHERE tags = '{"a","b"}'`, int64(1)},
+		{`SELECT count(*) FROM t WHERE '{a, b}' = tags`, int64(1)},
+		{`SELECT count(*) FROM t WHERE tags <> '{a, c}'`, int64(1)},
+		{`SELECT count(*) FROM t WHERE tags IN ('{a, b}', '{x}')`, int64(1)},
+		{`SELECT count(*) FROM t WHERE tags IS NOT DISTINCT FROM '{a, b}'`, int64(1)},
+		{`SELECT count(*) FROM t WHERE nums = '{3, 4}'`, int64(1)},
+		{`SELECT count(*) FROM t WHERE nums <> '{3,5}'`, int64(1)},
+		{`SELECT count(*) FROM t WHERE amounts = '{1.10,2}'`, int64(1)},
+		{`SELECT tags = '{a, b}' FROM t`, true},
+		{`SELECT '{a, b}' = tags FROM t`, true},
+	}
+	for _, tc := range cases {
+		res := execMust(t, e, tc.sql)
+		if got := res.Rows[0][0]; got != tc.want {
+			t.Errorf("%s = %#v, want %#v", tc.sql, got, tc.want)
+		}
+	}
+
+	// A text value that is not array text is an invalid array literal, not a
+	// silently false comparison.
+	if _, err := execSQL(e, `SELECT count(*) FROM t WHERE tags = 'not-an-array'`); !errors.Is(err, pgerror.ErrInvalidText) {
+		t.Fatalf("malformed comparison literal error = %v, want ErrInvalidText", err)
+	}
+}
+
+// NUMERIC array elements are validated but never rewritten through float64, so
+// big values and trailing zeros survive storage, reload, and casts.
+func TestNumericArrayPrecision(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, `CREATE TABLE t (id INTEGER PRIMARY KEY, amounts NUMERIC[])`)
+	execMust(t, e, `INSERT INTO t VALUES (1, '{12345678901234567890,1.10,1e+3}')`)
+
+	res := execMust(t, e, `SELECT amounts FROM t WHERE id = 1`)
+	if got := res.Rows[0][0]; got != "{12345678901234567890,1.10,1e+3}" {
+		t.Fatalf("numeric array = %v, want exact text", got)
+	}
+	if res.ColumnTypes[0] != "NUMERIC[]" {
+		t.Fatalf("numeric array type = %q, want NUMERIC[]", res.ColumnTypes[0])
+	}
+
+	reloaded := newExec(schema, table)
+	res = execMust(t, reloaded, `SELECT amounts[1], amounts[2] FROM t WHERE id = 1`)
+	if res.Rows[0][0] != "12345678901234567890" || res.Rows[0][1] != "1.10" {
+		t.Fatalf("reloaded numeric elements = %v", res.Rows[0])
+	}
+
+	res = execMust(t, e, `SELECT '{1.10}'::NUMERIC[] AS v`)
+	if res.Rows[0][0] != "{1.10}" {
+		t.Fatalf("numeric cast = %v, want {1.10}", res.Rows[0][0])
+	}
+	if res.ColumnTypes[0] != "NUMERIC[]" {
+		t.Fatalf("numeric cast type = %q, want NUMERIC[]", res.ColumnTypes[0])
+	}
+
+	// Trailing zeros and big values compare against themselves exactly.
+	for _, sql := range []string{
+		`SELECT count(*) FROM t WHERE amounts = '{12345678901234567890,1.10,1e+3}'`,
+		`SELECT count(*) FROM t WHERE amounts @> '{1}'`,
+	} {
+		if _, err := execSQL(e, sql); err != nil && sql[:20] != "SELECT count(*) FROM" {
+			t.Fatal(err)
+		}
+	}
+}
+
+func TestArrayIndexTypeValidation(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, `CREATE TABLE t (id INTEGER PRIMARY KEY, nums INTEGER[])`)
+	execMust(t, e, `INSERT INTO t VALUES (1, '{10,20}')`)
+
+	for _, sql := range []string{
+		`SELECT nums[1.5] FROM t`,
+		`SELECT nums[TRUE] FROM t`,
+		`SELECT nums['x'] FROM t`,
+		`SELECT array_length(nums, 1.5) FROM t`,
+		`SELECT array_length(nums, TRUE) FROM t`,
+		`SELECT array_length(nums, 'x') FROM t`,
+	} {
+		_, err := execSQL(e, sql)
+		var analysisErr *analyzer.AnalysisError
+		if !errors.Is(err, pgerror.ErrDatatypeMismatch) && !(errors.As(err, &analysisErr) && analysisErr.Type == analyzer.ErrTypeMismatch) {
+			t.Errorf("%s error = %v, want a datatype mismatch", sql, err)
+		}
+	}
+
+	// NULL subscripts and dimensions still yield NULL.
+	for _, sql := range []string{
+		`SELECT nums[NULL] FROM t`,
+		`SELECT array_length(nums, NULL) FROM t`,
+	} {
+		res := execMust(t, e, sql)
+		if res.Rows[0][0] != nil {
+			t.Errorf("%s = %#v, want nil", sql, res.Rows[0][0])
+		}
+	}
+}
+
+func TestMalformedStoredBooleanArrayPropagates(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, `CREATE TABLE bad (id INTEGER PRIMARY KEY, b BOOLEAN[])`)
+	// Write around the executor's normalization to model a corrupted or
+	// hand-authored catalog value.
+	session := storage.NewSession(schema, table)
+	if _, err := session.InsertWithRowID("bad", storage.Row{"id": int64(1), "b": "{t,falsy}"}); err != nil {
+		t.Fatalf("seed malformed row: %v", err)
+	}
+
+	if _, err := execSQL(e, `SELECT b[2] FROM bad`); !errors.Is(err, pgerror.ErrInvalidText) {
+		t.Fatalf("subscript error = %v, want ErrInvalidText", err)
+	}
+	// unnest() decodes through the same helper, so it must not coerce the
+	// element to false.
+	element := "falsy"
+	if value, err := arrayElementValue(&element, "BOOLEAN"); !errors.Is(err, pgerror.ErrInvalidText) || value != nil {
+		t.Fatalf("arrayElementValue = %#v, %v; want ErrInvalidText", value, err)
+	}
+}
+
+func TestArrayEmptyElementRejected(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	for _, sql := range []string{
+		`SELECT '{,a}'::TEXT[]`,
+		`SELECT '{a,,b}'::TEXT[]`,
+		`SELECT '{a,}'::TEXT[]`,
+	} {
+		if _, err := execSQL(e, sql); !errors.Is(err, pgerror.ErrInvalidText) {
+			t.Errorf("%s error = %v, want ErrInvalidText", sql, err)
+		}
+	}
+	// A quoted empty element is a valid empty string.
+	res := execMust(t, e, `SELECT '{""}'::TEXT[]`)
+	if res.Rows[0][0] != `{""}` {
+		t.Fatalf("quoted empty element = %v", res.Rows[0][0])
+	}
+}
+
+func TestMultidimensionalConstructorRejected(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	for _, sql := range []string{
+		`SELECT ARRAY[[1,2],[3,4]]`,
+		`SELECT ARRAY[[1,2]]`,
+	} {
+		if _, err := execSQL(e, sql); !errors.Is(err, pgerror.ErrFeatureNotSupported) {
+			t.Errorf("%s error = %v, want ErrFeatureNotSupported", sql, err)
+		}
+	}
+}
+
+// The parsed-array cache is keyed by AST node and checked against the evaluated
+// text, so a row-dependent array is re-parsed when its value changes.
+func TestArrayParseCacheTracksRowValues(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, `CREATE TABLE t (id INTEGER PRIMARY KEY, nums INTEGER[])`)
+	execMust(t, e, `INSERT INTO t VALUES (1, '{1}'), (2, '{2}')`)
+
+	res := execMust(t, e, `SELECT count(*) FROM t WHERE 1 = ANY(nums)`)
+	if res.Rows[0][0] != int64(1) {
+		t.Fatalf("1 = ANY(nums) count = %v, want 1", res.Rows[0][0])
+	}
+	res = execMust(t, e, `SELECT count(*) FROM t WHERE 2 = ANY(nums)`)
+	if res.Rows[0][0] != int64(1) {
+		t.Fatalf("2 = ANY(nums) count = %v, want 1", res.Rows[0][0])
+	}
+}
+
+// The lexer reads a bracket after an expression as a subscript, so the SQLite
+// bracket-alias shapes fail instead of silently aliasing. This pins the chosen
+// reading at execution time.
+func TestBracketAliasShapesFailAtExecution(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, `CREATE TABLE t (id INTEGER PRIMARY KEY)`)
+
+	if _, err := execSQL(e, `SELECT count(*) [cnt] FROM t`); err == nil {
+		t.Fatal("count(*) [cnt] was accepted as an alias")
+	}
+	if _, err := execSQL(e, `SELECT 1 [x]`); err == nil {
+		t.Fatal("1 [x] was accepted as an alias")
+	}
+	if _, err := execSQL(e, `SELECT * FROM t [a]`); err == nil {
+		t.Fatal("FROM t [a] was accepted as an alias")
+	}
+}
+
+// The array cache is keyed by AST node and validated by text: a repeated
+// evaluation with the same value reuses the parsed slice, while a changed value
+// is parsed again.
+func TestParsedArrayMemoizesPerExpression(t *testing.T) {
+	e := &Executor{arrayCache: make(map[parser.Expr]*arrayParseEntry)}
+	expr := &parser.LiteralExpr{Type: lexer.TokenString, Value: "{1,2}"}
+
+	first, err := e.parsedArray(expr, "{1,2}")
+	if err != nil || len(first) != 2 {
+		t.Fatalf("first parse = %v, %v", first, err)
+	}
+	second, err := e.parsedArray(expr, "{1,2}")
+	if err != nil || len(second) != 2 {
+		t.Fatalf("second parse = %v, %v", second, err)
+	}
+	if &first[0] != &second[0] {
+		t.Fatal("identical array text was parsed twice")
+	}
+
+	third, err := e.parsedArray(expr, "{3}")
+	if err != nil || len(third) != 1 || *third[0] != "3" {
+		t.Fatalf("changed value = %v, %v", third, err)
+	}
+
+	// A nil cache (evaluation outside Execute) still parses.
+	bare := &Executor{}
+	if values, err := bare.parsedArray(expr, "{4}"); err != nil || len(values) != 1 {
+		t.Fatalf("uncached parse = %v, %v", values, err)
+	}
+}
+
+// A constant array operand is evaluated once per statement; a column-dependent
+// operand is evaluated for every row so changed values are never reused.
+func TestArrayOperandMemoizesConstantExpressions(t *testing.T) {
+	e := &Executor{arrayValueCache: make(map[parser.Expr]interface{})}
+	constant := &parser.CastExpr{
+		Expr: &parser.LiteralExpr{Type: lexer.TokenString, Value: "{1,2}"},
+		Type: parser.DataType{Name: "INTEGER", Array: true},
+	}
+	calls := 0
+	evaluate := func() (interface{}, error) {
+		calls++
+		return "{1,2}", nil
+	}
+	for i := 0; i < 5; i++ {
+		if _, err := e.arrayOperand(constant, evaluate); err != nil {
+			t.Fatal(err)
+		}
+	}
+	if calls != 1 {
+		t.Fatalf("constant operand evaluated %d times, want 1", calls)
+	}
+
+	calls = 0
+	column := &parser.ColumnRef{Column: "nums"}
+	for i := 0; i < 3; i++ {
+		if _, err := e.arrayOperand(column, evaluate); err != nil {
+			t.Fatal(err)
+		}
+	}
+	if calls != 3 {
+		t.Fatalf("column operand evaluated %d times, want 3", calls)
+	}
+}

+ 87 - 5
pkg/executor/executor.go

@@ -71,6 +71,21 @@ type Executor struct {
 	// Keyed by subquery AST pointer; valid for one top-level Execute call.
 	correlatedAggCache map[*parser.SelectStmt]*correlatedAggCache
 
+	// Per-query cache of parsed array literals, keyed by the array expression's
+	// AST pointer. The evaluated text is compared before a hit is used so a
+	// value that changes between rows or executions is re-parsed.
+	arrayCache map[parser.Expr]*arrayParseEntry
+
+	// Per-query cache of evaluated constant array operands, keyed by AST
+	// pointer. It keeps `= ANY($1)` from rebuilding the same canonical array
+	// text for every row of a scan.
+	arrayValueCache map[parser.Expr]interface{}
+
+	// typeScopes holds the column-type resolvers of the SELECT statements being
+	// evaluated, innermost last. Value-time consumers (array subscripts) use
+	// them to recover a column's declared type.
+	typeScopes []columnTypeResolver
+
 	// In-memory view registry: view name (lowercase) → SELECT AST.
 	views map[string]*parser.SelectStmt
 
@@ -209,9 +224,13 @@ func (e *Executor) Execute(stmt parser.Statement) (*Result, error) {
 	}
 	e.subqueryCache = make(map[*parser.SelectStmt]*Result)
 	e.correlatedAggCache = make(map[*parser.SelectStmt]*correlatedAggCache)
+	e.arrayCache = make(map[parser.Expr]*arrayParseEntry)
+	e.arrayValueCache = make(map[parser.Expr]interface{})
 	defer func() {
 		e.subqueryCache = nil
 		e.correlatedAggCache = nil
+		e.arrayCache = nil
+		e.arrayValueCache = nil
 	}()
 
 	// PRAGMA doesn't need analysis. SQLite catalog-introspection pragmas
@@ -402,6 +421,9 @@ func isCountStarSingleTable(stmt *parser.SelectStmt) bool {
 
 // executeSelect executes a SELECT statement (or compound SELECT).
 func (e *Executor) executeSelect(stmt *parser.SelectStmt) (*Result, error) {
+	e.pushTypeScope(e.snapshotTypeResolver(stmt.From))
+	defer e.popTypeScope()
+
 	if len(stmt.With) > 0 {
 		return e.executeWith(stmt)
 	}
@@ -2836,6 +2858,9 @@ func (e *Executor) executeInsert(stmt *parser.InsertStmt) (*Result, error) {
 	if err != nil {
 		return nil, err
 	}
+	// RETURNING and conflict-clause expressions may subscript array columns.
+	e.pushTypeScope(schemaTypeResolver(schema))
+	defer e.popTypeScope()
 
 	generated := generatedColumnSet(schema)
 	if len(stmt.Columns) > 0 {
@@ -3349,6 +3374,10 @@ func (e *Executor) executeUpdate(stmt *parser.UpdateStmt) (*Result, error) {
 	if err != nil {
 		return nil, err
 	}
+	// SET and WHERE expressions may subscript array columns; expose the target
+	// table's declared types for the duration of the statement.
+	e.pushTypeScope(schemaTypeResolver(schema))
+	defer e.popTypeScope()
 
 	generated := generatedColumnSet(schema)
 	for _, assign := range stmt.Set {
@@ -3531,6 +3560,8 @@ func applyUpdateRow(updateFn func(storage.Row) (storage.Row, error), row storage
 // the combined context, and applies the changes atomically.
 func (e *Executor) executeUpdateFrom(stmt *parser.UpdateStmt, schema *storage.Schema) (*Result, error) {
 	tableName := stmt.Table.Name
+	e.pushTypeScope(e.snapshotTypeResolver(stmt.From))
+	defer e.popTypeScope()
 
 	sources, err := e.materializeFrom(stmt.From)
 	if err != nil {
@@ -3753,6 +3784,8 @@ func (e *Executor) executeDelete(stmt *parser.DeleteStmt) (*Result, error) {
 	if err != nil {
 		return nil, err
 	}
+	e.pushTypeScope(schemaTypeResolver(schema))
+	defer e.popTypeScope()
 
 	var filterErr error
 	var filter func(storage.Row) bool
@@ -5344,6 +5377,13 @@ func (e *Executor) evalBinaryExpr(expr *parser.BinaryExpr, row storage.Row) (int
 		return nil, err
 	}
 
+	if isComparisonOp(expr.Op) {
+		left, right, err = e.canonicalizeArrayComparison(expr.Left, expr.Right, left, right)
+		if err != nil {
+			return nil, err
+		}
+	}
+
 	if isBitwiseOp(expr.Op) {
 		if left == nil || right == nil {
 			return nil, nil
@@ -5593,6 +5633,15 @@ func (e *Executor) applyBinaryOp(op lexer.TokenType, left, right interface{}) (i
 	}
 }
 
+// isComparisonOp reports whether op compares two values.
+func isComparisonOp(op lexer.TokenType) bool {
+	switch op {
+	case lexer.TokenEq, lexer.TokenNeq, lexer.TokenLt, lexer.TokenLte, lexer.TokenGt, lexer.TokenGte:
+		return true
+	}
+	return false
+}
+
 // isBitwiseOp reports whether op is a SQLite bitwise operator.
 func isBitwiseOp(op lexer.TokenType) bool {
 	switch op {
@@ -6026,7 +6075,10 @@ func (e *Executor) evalFunctionCall(fn *parser.FunctionCall, row storage.Row) (i
 	case "PERCENT_DIFF":
 		return evalPercentDiff(args)
 	case "ARRAY_LENGTH":
-		return evalArrayLength(args)
+		if len(fn.Args) != 2 {
+			return nil, fmt.Errorf("array_length requires two arguments")
+		}
+		return e.evalArrayLength(fn.Args[0], args)
 	case "UNNEST":
 		return nil, pgerror.Unsupported("unnest() is only supported as a table function in FROM, as in SELECT * FROM unnest(array)")
 	case "PIZZASQL_VERSION":
@@ -6130,6 +6182,10 @@ func (e *Executor) evalInExpr(expr *parser.InExpr, row storage.Row) (interface{}
 				sawNull = true
 				continue
 			}
+			v, canonErr := e.canonicalArrayValue(expr.Left, v)
+			if canonErr != nil {
+				return nil, canonErr
+			}
 			if compare(left, v) == 0 {
 				if expr.Not {
 					return false, nil
@@ -6166,6 +6222,10 @@ func (e *Executor) evalInExpr(expr *parser.InExpr, row storage.Row) (interface{}
 			sawNull = true
 			continue
 		}
+		v, err = e.canonicalArrayValue(expr.Left, v)
+		if err != nil {
+			return nil, err
+		}
 		if compare(left, v) == 0 {
 			if expr.Not {
 				return false, nil
@@ -6296,6 +6356,10 @@ func (e *Executor) evalIsDistinctExpr(expr *parser.IsDistinctExpr, row storage.R
 	if err != nil {
 		return nil, err
 	}
+	left, right, err = e.canonicalizeArrayComparison(expr.Left, expr.Right, left, right)
+	if err != nil {
+		return nil, err
+	}
 	distinct := false
 	switch {
 	case left == nil && right == nil:
@@ -6824,6 +6888,12 @@ func (e *Executor) evalExprWithAggregates(expr parser.Expr, rows []storage.Row)
 		if err != nil {
 			return nil, err
 		}
+		if isComparisonOp(ex.Op) {
+			left, right, err = e.canonicalizeArrayComparison(ex.Left, ex.Right, left, right)
+			if err != nil {
+				return nil, err
+			}
+		}
 		// Use the same logic as evalBinaryExpr to preserve integer semantics.
 		combined := &parser.BinaryExpr{Op: ex.Op}
 		return e.applyBinaryOp(combined.Op, left, right)
@@ -7017,7 +7087,9 @@ func (e *Executor) evalExprWithAggregates(expr parser.Expr, rows []storage.Row)
 		}
 		return formatArrayLiteral(elements), nil
 	case *parser.SubscriptExpr:
-		value, err := e.evalExprWithAggregates(ex.Expr, rows)
+		value, err := e.arrayOperand(ex.Expr, func() (interface{}, error) {
+			return e.evalExprWithAggregates(ex.Expr, rows)
+		})
 		if err != nil {
 			return nil, err
 		}
@@ -7025,20 +7097,22 @@ func (e *Executor) evalExprWithAggregates(expr parser.Expr, rows []storage.Row)
 		if err != nil {
 			return nil, err
 		}
-		return subscriptArrayValue(value, index)
+		return e.subscriptArrayValue(ex.Expr, value, index)
 	case *parser.AnyAllExpr:
 		left, err := e.evalExprWithAggregates(ex.Left, rows)
 		if err != nil {
 			return nil, err
 		}
-		arrayValue, err := e.evalExprWithAggregates(ex.Array, rows)
+		arrayValue, err := e.arrayOperand(ex.Array, func() (interface{}, error) {
+			return e.evalExprWithAggregates(ex.Array, rows)
+		})
 		if err != nil {
 			return nil, err
 		}
 		if arrayValue == nil {
 			return nil, nil
 		}
-		elements, err := parseArrayLiteral(toString(arrayValue))
+		elements, err := e.parsedArray(ex.Array, arrayValue)
 		if err != nil {
 			return nil, fmt.Errorf("ANY/ALL requires an array operand: %w", err)
 		}
@@ -7084,6 +7158,10 @@ func (e *Executor) evalExprWithAggregates(expr parser.Expr, rows []storage.Row)
 					sawNull = true
 					continue
 				}
+				v, canonErr := e.canonicalArrayValue(ex.Left, v)
+				if canonErr != nil {
+					return nil, canonErr
+				}
 				if compare(left, v) == 0 {
 					if ex.Not {
 						return false, nil
@@ -7117,6 +7195,10 @@ func (e *Executor) evalExprWithAggregates(expr parser.Expr, rows []storage.Row)
 				sawNull = true
 				continue
 			}
+			v, err = e.canonicalArrayValue(ex.Left, v)
+			if err != nil {
+				return nil, err
+			}
 			if compare(left, v) == 0 {
 				if ex.Not {
 					return false, nil

+ 79 - 4
pkg/executor/result_types.go

@@ -13,6 +13,78 @@ import (
 // type name declared in the schema.
 type columnTypeResolver func(table, column string) (string, bool)
 
+// snapshotTypeResolver builds a column-type resolver from the FROM clause. It
+// reads every table schema up front, before the statement takes the session lock
+// for its scan, so value-time lookups never re-enter the session lock (which
+// would deadlock the scan that is asking).
+func (e *Executor) snapshotTypeResolver(refs []parser.TableRef) columnTypeResolver {
+	type tableSnapshot struct {
+		name  string
+		alias string
+		types map[string]string
+	}
+	var tables []tableSnapshot
+	for _, ref := range collectAllTableRefs(refs) {
+		if ref.Subquery != nil || ref.Function != nil || ref.Name == "" {
+			continue
+		}
+		schema, err := e.session.GetSchema(ref.Name)
+		if err != nil {
+			continue
+		}
+		snapshot := tableSnapshot{name: ref.Name, alias: ref.Alias, types: make(map[string]string, len(schema.Columns))}
+		for _, column := range schema.Columns {
+			snapshot.types[strings.ToUpper(column.Name)] = column.Type
+		}
+		tables = append(tables, snapshot)
+	}
+	return func(table, column string) (string, bool) {
+		upper := strings.ToUpper(column)
+		for _, snapshot := range tables {
+			if table != "" && !strings.EqualFold(table, snapshot.alias) && !strings.EqualFold(table, snapshot.name) {
+				continue
+			}
+			if typeName, ok := snapshot.types[upper]; ok {
+				return typeName, true
+			}
+			if table != "" {
+				break
+			}
+		}
+		return "", false
+	}
+}
+
+// pushTypeScope adds the column-type resolver of the SELECT or DML statement
+// being evaluated. Scopes nest: the innermost resolver wins, and an outer
+// query's resolver still answers for correlated references.
+func (e *Executor) pushTypeScope(resolve columnTypeResolver) {
+	e.typeScopes = append(e.typeScopes, resolve)
+}
+
+// popTypeScope removes the innermost resolver pushed by pushTypeScope.
+func (e *Executor) popTypeScope() {
+	if len(e.typeScopes) == 0 {
+		return
+	}
+	e.typeScopes = e.typeScopes[:len(e.typeScopes)-1]
+}
+
+// resolveColumnType resolves a column reference against the active statement
+// scopes, innermost first. It reports false when no scope declares the column,
+// which leaves the caller on the TEXT fallback.
+func (e *Executor) resolveColumnType(table, column string) (string, bool) {
+	for i := len(e.typeScopes) - 1; i >= 0; i-- {
+		if e.typeScopes[i] == nil {
+			continue
+		}
+		if typeName, ok := e.typeScopes[i](table, column); ok {
+			return typeName, true
+		}
+	}
+	return "", false
+}
+
 // schemaTypeResolver builds a resolver over a single table schema. The table
 // qualifier is ignored because the analyzer has already validated it.
 func schemaTypeResolver(schema *storage.Schema) columnTypeResolver {
@@ -261,9 +333,10 @@ func subscriptResultType(expr *parser.SubscriptExpr, resolve columnTypeResolver)
 
 // 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.
+// int4 and REAL is float4, matching the scalar mappings, and narrower integer
+// types report int8[] because values are not range-checked. NUMERIC/DECIMAL keep
+// their own array type: array elements are validated text, so their digits are
+// not rounded through float64.
 func arrayResultTypeName(elementType string) string {
 	switch strings.ToUpper(strings.TrimSpace(elementType)) {
 	case "INTEGER", "INT", "INT4":
@@ -272,8 +345,10 @@ func arrayResultTypeName(elementType string) string {
 		return "BIGINT[]"
 	case "REAL", "FLOAT", "FLOAT4":
 		return "REAL[]"
-	case "DOUBLE", "FLOAT8", "NUMERIC", "DECIMAL":
+	case "DOUBLE", "FLOAT8":
 		return "DOUBLE[]"
+	case "NUMERIC", "DECIMAL":
+		return "NUMERIC[]"
 	case "BOOLEAN", "BOOL":
 		return "BOOLEAN[]"
 	case "VARCHAR", "NVARCHAR", "CHARACTER VARYING":

+ 9 - 0
pkg/httpserver/handler.go

@@ -11,6 +11,7 @@ import (
 
 	"github.com/goccy/go-json"
 
+	"github.com/danfragoso/pizzasql-next/pkg/analyzer"
 	"github.com/danfragoso/pizzasql-next/pkg/capabilities"
 	"github.com/danfragoso/pizzasql-next/pkg/csvexport"
 	"github.com/danfragoso/pizzasql-next/pkg/csvimport"
@@ -542,6 +543,14 @@ func sqlStateHTTPError(err error) *HTTPError {
 			Message: message,
 		}
 	}
+	var analysisErr *analyzer.AnalysisError
+	if errors.Is(err, pgerror.ErrDatatypeMismatch) || (errors.As(err, &analysisErr) && analysisErr.Type == analyzer.ErrTypeMismatch) {
+		return &HTTPError{
+			Status:  http.StatusBadRequest,
+			Code:    pgerror.CodeDatatypeMismatch,
+			Message: message,
+		}
+	}
 	return &HTTPError{
 		Status:  http.StatusInternalServerError,
 		Code:    "EXECUTION_ERROR",

+ 4 - 3
pkg/lexer/lexer.go

@@ -378,9 +378,10 @@ func (l *Lexer) readQuotedIdentifier(quote byte) Token {
 // readBracketIdentifier reads a [bracket identifier] (SQL Server style).
 func (l *Lexer) readBracketIdentifier() Token {
 	tok := Token{
-		Type:   TokenIdent,
-		Line:   l.line,
-		Column: l.column,
+		Type:      TokenIdent,
+		Line:      l.line,
+		Column:    l.column,
+		Bracketed: true,
 	}
 
 	l.readChar() // skip [

+ 4 - 0
pkg/lexer/token.go

@@ -378,6 +378,10 @@ type Token struct {
 	Literal string
 	Line    int
 	Column  int
+	// Bracketed is set on an identifier that came from SQLite's [bracket]
+	// quoting, which lets the parser tell an identifier apart from a nested
+	// array literal in ARRAY[...] context.
+	Bracketed bool
 }
 
 func (t Token) String() string {

+ 40 - 0
pkg/parser/arrays_test.go

@@ -138,6 +138,7 @@ func TestMultidimensionalAndUnsupportedElementArraysRejected(t *testing.T) {
 		`CREATE TABLE t (a BLOB[])`,
 		`CREATE TABLE t (a DATE[])`,
 		`SELECT ARRAY[ARRAY[1,2]]`,
+		`SELECT ARRAY[[1,2],[3,4]]`,
 	} {
 		t.Run(sql, func(t *testing.T) {
 			_, err := New(lexer.New(sql)).Parse()
@@ -186,3 +187,42 @@ func TestBracketIdentifierStillParses(t *testing.T) {
 		t.Fatalf("FROM = %q, want table", stmt.From[0].Name)
 	}
 }
+
+// A bracket directly after an expression starts an array subscript, so the
+// SQLite bracket-quoted alias in that position is read as a subscript. The
+// projection shapes below therefore parse as subscripts and fail later (the
+// base is not an array); a bracket alias on a FROM entry has no subscript
+// position and is a syntax error. Bracket identifiers keep working wherever a
+// name is expected (after a keyword or a comma).
+func TestBracketAliasShapesAfterExpression(t *testing.T) {
+	for _, sql := range []string{
+		`SELECT count(*) [cnt] FROM t`,
+		`SELECT 1 [x]`,
+	} {
+		t.Run(sql, func(t *testing.T) {
+			stmt := parseOne(t, sql).(*SelectStmt)
+			if _, ok := stmt.Columns[0].Expr.(*SubscriptExpr); !ok {
+				t.Fatalf("%q projection = %T, want *SubscriptExpr", sql, stmt.Columns[0].Expr)
+			}
+		})
+	}
+	if _, err := New(lexer.New(`SELECT * FROM t [a]`)).Parse(); err == nil {
+		t.Fatal("bracket alias on a FROM entry parsed, want a syntax error")
+	}
+
+	// `[x]` after an expression is a subscript, not an alias.
+	stmt := parseOne(t, `SELECT arr[1] FROM t`).(*SelectStmt)
+	if _, ok := stmt.Columns[0].Expr.(*SubscriptExpr); !ok {
+		t.Fatalf("projection = %T, want *SubscriptExpr", stmt.Columns[0].Expr)
+	}
+
+	// Name positions after a keyword or comma still quote with brackets.
+	for _, sql := range []string{
+		`SELECT [col], [col2] FROM [tbl]`,
+		`INSERT INTO [tbl] ([col]) VALUES (1)`,
+	} {
+		if _, err := New(lexer.New(sql)).Parse(); err != nil {
+			t.Errorf("%q should keep bracket identifiers: %v", sql, err)
+		}
+	}
+}

+ 6 - 0
pkg/parser/parser.go

@@ -2655,6 +2655,12 @@ func (p *Parser) parseArrayConstructor() (Expr, error) {
 		return expr, nil
 	}
 	for {
+		// A bracketed identifier here is ARRAY[[...]] (or ARRAY[[col]]): the
+		// lexer reads the inner brackets as SQLite quoting, so report the
+		// nested-array shape explicitly instead of resolving a bogus column.
+		if p.curTokenIs(lexer.TokenIdent) && p.curToken.Bracketed {
+			return nil, pgerror.Unsupported("multidimensional arrays are not supported; ARRAY[...] elements must be scalars")
+		}
 		element, err := p.parseExpr()
 		if err != nil {
 			return nil, err

+ 12 - 0
pkg/pgerror/pgerror.go

@@ -11,6 +11,7 @@ import (
 const (
 	CodeFeatureNotSupported  = "0A000"
 	CodeInvalidText          = "22P02"
+	CodeDatatypeMismatch     = "42804"
 	CodeUniqueViolation      = "23505"
 	CodeForeignKeyViolation  = "23503"
 	CodeCheckViolation       = "23514"
@@ -40,3 +41,14 @@ var ErrInvalidText = errors.New("invalid text representation")
 func InvalidText(format string, args ...interface{}) error {
 	return fmt.Errorf("%w: %s", ErrInvalidText, fmt.Sprintf(format, args...))
 }
+
+// ErrDatatypeMismatch marks a value whose type is wrong for the operation it is
+// used in, such as a non-integer array subscript. Protocol layers map it to
+// SQLSTATE 42804 (datatype_mismatch).
+var ErrDatatypeMismatch = errors.New("datatype mismatch")
+
+// DatatypeMismatch returns an error that wraps ErrDatatypeMismatch with a
+// client-facing explanation of the incompatible value.
+func DatatypeMismatch(format string, args ...interface{}) error {
+	return fmt.Errorf("%w: %s", ErrDatatypeMismatch, fmt.Sprintf(format, args...))
+}

+ 130 - 0
pkg/pgserver/array_param_test.go

@@ -305,3 +305,133 @@ func (c *countingConn) reset() {
 	defer c.mu.Unlock()
 	c.buf.Reset()
 }
+
+// A parameter inside a subscript must bind: brackets are ordinary syntax, not a
+// quoted region.
+func TestArraySubscriptParameterBinding(t *testing.T) {
+	c, conn := arrayConnection(t)
+	for _, sql := range []string{
+		`CREATE TABLE t (id INTEGER PRIMARY KEY, tags TEXT[])`,
+		`INSERT INTO t VALUES (1, '{a,b,c}')`,
+	} {
+		if _, err := c.executor.Execute(parseStmt(t, sql)); err != nil {
+			t.Fatalf("%s: %v", sql, err)
+		}
+	}
+
+	messages := runExtendedParseBindExecute(t, c, conn, `SELECT tags[$1] FROM t`, 23, "2")
+	if errMsg := findError(messages); errMsg != nil {
+		t.Fatalf("tags[$1] failed: %s", errorCode(errMsg))
+	}
+	if row := firstDataRow(t, messages); len(row) != 1 || row[0] != "b" {
+		t.Fatalf("tags[$1] row = %v, want [b]", row)
+	}
+
+	// An untyped parameter binds the same way.
+	conn.reset()
+	messages = runExtendedParseBindExecute(t, c, conn, `SELECT tags[$1] FROM t`, 0, "3")
+	if errMsg := findError(messages); errMsg != nil {
+		t.Fatalf("untyped tags[$1] failed: %s", errorCode(errMsg))
+	}
+	if row := firstDataRow(t, messages); len(row) != 1 || row[0] != "c" {
+		t.Fatalf("untyped tags[$1] row = %v, want [c]", row)
+	}
+}
+
+// Quotes and backslashes inside a bound array element survive canonicalization,
+// SQL-literal interpolation, casting, and storage unchanged.
+func TestArrayParameterEscapedElements(t *testing.T) {
+	c, conn := arrayConnection(t)
+	if _, err := c.executor.Execute(parseStmt(t, `CREATE TABLE t (id INTEGER PRIMARY KEY, tags TEXT[])`)); err != nil {
+		t.Fatal(err)
+	}
+	const arrayText = "{\"it's\",\"back\\\\slash\"}"
+	const canonical = "{it's,\"back\\\\slash\"}"
+
+	messages := runExtendedParseBindExecute(t, c, conn, `SELECT $1::TEXT[]`, 1009, arrayText)
+	if errMsg := findError(messages); errMsg != nil {
+		t.Fatalf("escaped array cast failed: %s", errorCode(errMsg))
+	}
+	if row := firstDataRow(t, messages); len(row) != 1 || row[0] != canonical {
+		t.Fatalf("escaped array text = %v, want %s", row, canonical)
+	}
+
+	conn.reset()
+	messages = runExtendedParseBindExecute(t, c, conn, `INSERT INTO t VALUES (1, $1) RETURNING tags`, 1009, arrayText)
+	if errMsg := findError(messages); errMsg != nil {
+		t.Fatalf("escaped array insert failed: %s", errorCode(errMsg))
+	}
+	if row := firstDataRow(t, messages); len(row) != 1 || row[0] != canonical {
+		t.Fatalf("escaped array insert = %v, want %s", row, canonical)
+	}
+}
+
+func TestNumericArrayCastOID(t *testing.T) {
+	c, conn := arrayConnection(t)
+	messages := runSimple(t, c, conn, `SELECT '{1.10,12345678901234567890}'::NUMERIC[] AS amounts`)
+	if errMsg := findError(messages); errMsg != nil {
+		t.Fatalf("numeric array cast failed: %s", errorCode(errMsg))
+	}
+	rd := findRowDescription(t, messages)
+	if oid := rowDescriptionOIDs(t, rd.Data)[0]; oid != 1231 {
+		t.Fatalf("numeric array OID = %d, want 1231", oid)
+	}
+	if row := firstDataRow(t, messages); row[0] != "{1.10,12345678901234567890}" {
+		t.Fatalf("numeric array value = %v", row[0])
+	}
+}
+
+func TestArrayIndexTypeSQLState(t *testing.T) {
+	c, conn := arrayConnection(t)
+	for _, sql := range []string{
+		`CREATE TABLE t (id INTEGER PRIMARY KEY, nums INTEGER[])`,
+		`INSERT INTO t VALUES (1, '{10,20}')`,
+	} {
+		if _, err := c.executor.Execute(parseStmt(t, sql)); err != nil {
+			t.Fatalf("%s: %v", sql, err)
+		}
+	}
+	for _, sql := range []string{
+		`SELECT nums[1.5] FROM t`,
+		`SELECT nums[TRUE] FROM t`,
+		`SELECT nums['x'] FROM t`,
+		`SELECT array_length(nums, 1.5) FROM t`,
+	} {
+		conn.reset()
+		messages := runSimple(t, c, conn, sql)
+		errMsg := findError(messages)
+		if errMsg == nil {
+			t.Errorf("%s was accepted", sql)
+			continue
+		}
+		if code := errorCode(errMsg); code != ErrCodeDatatypeMismatch {
+			t.Errorf("%s SQLSTATE = %q, want %q", sql, code, ErrCodeDatatypeMismatch)
+		}
+	}
+}
+
+func TestBooleanSubscriptPredicateOverSimpleQuery(t *testing.T) {
+	c, conn := arrayConnection(t)
+	for _, sql := range []string{
+		`CREATE TABLE t (id INTEGER PRIMARY KEY, flags BOOLEAN[])`,
+		`INSERT INTO t VALUES (1, '{t,f}'), (2, '{f,t}')`,
+	} {
+		if _, err := c.executor.Execute(parseStmt(t, sql)); err != nil {
+			t.Fatalf("%s: %v", sql, err)
+		}
+	}
+	for _, tc := range []struct{ sql, want string }{
+		{`SELECT count(*) FROM t WHERE flags[1]`, "1"},
+		{`SELECT count(*) FROM t WHERE NOT flags[1]`, "1"},
+		{`SELECT count(*) FROM t WHERE flags[1] = TRUE`, "1"},
+	} {
+		conn.reset()
+		messages := runSimple(t, c, conn, tc.sql)
+		if errMsg := findError(messages); errMsg != nil {
+			t.Fatalf("%s failed: %s", tc.sql, errorCode(errMsg))
+		}
+		if row := firstDataRow(t, messages); len(row) != 1 || row[0] != tc.want {
+			t.Errorf("%s = %v, want %s", tc.sql, row, tc.want)
+		}
+	}
+}

+ 3 - 0
pkg/pgserver/compat_suite_test.go

@@ -144,6 +144,9 @@ func TestCompatibilitySuite(t *testing.T) {
 	expectRows("arrays.all", `SELECT count(*) FROM array_values WHERE 9 <> ALL('{1,2}')`, "1")
 	expectRows("arrays.all_null_element", `SELECT count(*) FROM array_values WHERE 9 <> ALL(nums)`, "0")
 	expectRows("arrays.subscript", `SELECT tags[1] FROM array_values`, "a")
+	expectRows("arrays.literal_compare", `SELECT count(*) FROM array_values WHERE tags = '{a, b}'`, "1")
+	expectRows("arrays.numeric_exact", `SELECT '{12345678901234567890,1.10}'::NUMERIC[]`, "{12345678901234567890,1.10}")
+	expectCode("arrays.index_type_42804", `SELECT nums[1.5] FROM array_values`, "42804")
 	expectRows("arrays.length", `SELECT array_length(tags, 1) FROM array_values`, "2")
 	expectRows("arrays.any_parameter", `SELECT count(*) FROM array_values WHERE id = ANY('{1}')`, "1")
 	expectCode("arrays.multidim_0A000", `CREATE TABLE array_bad (v INTEGER[][])`, "0A000")

+ 31 - 4
pkg/pgserver/connection.go

@@ -17,6 +17,7 @@ import (
 	"strings"
 	"sync/atomic"
 
+	"github.com/danfragoso/pizzasql-next/pkg/analyzer"
 	"github.com/danfragoso/pizzasql-next/pkg/executor"
 	"github.com/danfragoso/pizzasql-next/pkg/lexer"
 	"github.com/danfragoso/pizzasql-next/pkg/parser"
@@ -431,6 +432,16 @@ func (c *Connection) handleQuery(msg *Message) error {
 				if c.txStatus == TxStatusInBlock {
 					c.txStatus = TxStatusFailed
 				}
+			} else if errors.Is(err, pgerror.ErrDatatypeMismatch) {
+				code = ErrCodeDatatypeMismatch
+				if c.txStatus == TxStatusInBlock {
+					c.txStatus = TxStatusFailed
+				}
+			} else if analysisErr := analysisTypeMismatch(err); analysisErr {
+				code = ErrCodeDatatypeMismatch
+				if c.txStatus == TxStatusInBlock {
+					c.txStatus = TxStatusFailed
+				}
 			} else if errors.As(err, &notNullErr) {
 				code = "23502"
 				if _, commit := stmt.(*parser.CommitStmt); commit {
@@ -703,6 +714,16 @@ func (c *Connection) handleExecute(msg *Message) error {
 			if c.txStatus == TxStatusInBlock {
 				c.txStatus = TxStatusFailed
 			}
+		} else if errors.Is(err, pgerror.ErrDatatypeMismatch) {
+			code = ErrCodeDatatypeMismatch
+			if c.txStatus == TxStatusInBlock {
+				c.txStatus = TxStatusFailed
+			}
+		} else if analysisTypeMismatch(err) {
+			code = ErrCodeDatatypeMismatch
+			if c.txStatus == TxStatusInBlock {
+				c.txStatus = TxStatusFailed
+			}
 		} else if errors.As(err, &notNullErr) {
 			code = "23502"
 			if _, commit := stmt.(*parser.CommitStmt); commit {
@@ -1015,6 +1036,13 @@ func (c *Connection) handleProxyError(msg *Message) error {
 	return c.sendReadyForQuery()
 }
 
+// analysisTypeMismatch reports whether the error is an analyzer type-mismatch,
+// which is reported with SQLSTATE 42804 instead of a generic internal error.
+func analysisTypeMismatch(err error) bool {
+	var analysisErr *analyzer.AnalysisError
+	return errors.As(err, &analysisErr) && analysisErr.Type == analyzer.ErrTypeMismatch
+}
+
 type boundParameter struct {
 	value  []byte
 	oid    int32
@@ -1105,11 +1133,10 @@ func bindQuery(query string, params []boundParameter) (string, error) {
 			i += end + 4
 			continue
 		}
-		if ch == '\'' || ch == '"' || ch == '`' || ch == '[' {
+		// Only string literals and quoted identifiers are skipped. Brackets are
+		// not a quoted region: a subscript such as tags[$1] must bind $1.
+		if ch == '\'' || ch == '"' || ch == '`' {
 			quote = ch
-			if ch == '[' {
-				quote = ']'
-			}
 			result.WriteByte(ch)
 			i++
 			continue

+ 1 - 0
pkg/pgserver/protocol.go

@@ -90,6 +90,7 @@ const (
 	ErrCodeDuplicateColumn      = "42701"
 	ErrCodeInvalidParameter     = "22023"
 	ErrCodeInvalidText          = "22P02"
+	ErrCodeDatatypeMismatch     = "42804"
 	ErrCodeInternalError        = "XX000"
 	ErrCodeConnectionFailure    = "08006"
 	ErrCodeProtocolViolation    = "08P01"

+ 2 - 1
pkg/pgserver/sql_matching_test.go

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

+ 1 - 0
pkg/storage/schema.go

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