Bläddra i källkod

propagate array types through derived tables and CTEs; exact NUMERIC compare

Round 3 review fixes.

P1 - typing survives derived tables and CTEs:
- Materialized relations carry their result column types into a resolver pushed
  for the outer statement (derived tables, comma-separated derived tables,
  CTEs, views, and unnest). CTEs store the ColumnTypes their SELECT reported,
  including the anchor's types for recursive CTEs.
- A per-statement join scope records the types of right-hand derived tables and
  CTEs as they are materialized, so the outer WHERE/projection can decode
  array elements there too.
- snapshotTypeResolver skips names that resolve through cteTableFor, so a CTE
  shadowing a real table no longer picks the table's type (which could report a
  spurious 22P02 or the wrong boolean behavior).
- CHECK constraints and generated columns still evaluate through the stateless
  evaluator without a table schema; that gap is documented in capabilities.

P2:
- runWithTimeout test covering BEGIN; SELECT count(*) ... WHERE flags[1];
  UPDATE ...; COMMIT, pinning the deadlock fix for a locked in-transaction scan.
- NUMERIC comparisons are exact. Scalar element comparisons use big.Rat instead
  of float64, whole-array equality for NUMERIC[] compares element-wise with
  NULL elements equal, and IS DISTINCT FROM/IN use the same path. UNIQUE indexes
  and primary keys still use the canonical raw text (1.10 vs 1.1 are distinct
  keys), which the capability document now states.
- NUMERIC element validation rejects hex floats, NaN, and Inf, trims quoted
  element text, and normalizes a leading +.
- Constant array comparison literals are canonicalized once per statement,
  memoized by AST node and source text like the subscript/ANY caches.
Danilo Fragoso 2 dagar sedan
förälder
incheckning
d5f55d551d

BIN
bin/pizzasql


+ 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 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"},
+			Arrays:   Feature{Supported: true, Status: "one_dimensional", SQLState: "0A000", Notes: "column types TEXT[], VARCHAR(n)[], INTEGER[], BIGINT[], SMALLINT[], BOOLEAN[], REAL[], DOUBLE PRECISION[], and NUMERIC[] in CREATE TABLE and ALTER TABLE ADD COLUMN; values are PostgreSQL array text ({a,b},NULL) stored canonically, with NULL and the empty array distinct; literals, ARRAY[...], ::type[] and CAST(x AS type[]), 1-based arr[n], array_length(arr, 1), and unnest(array) as the single FROM table function are supported; multidimensional arrays, arrays of other element types, and ANY/ALL over subqueries are rejected with 0A000; malformed array input reports 22P02 and a non-integer subscript or array_length dimension reports 42804; NUMERIC[] and DECIMAL[] elements are validated but stored as their exact text, so digits above 2^53 and trailing zeros survive, and NUMERIC comparisons are exact (1.10 = 1.1, and digits above 2^53 are not rounded); column types survive derived tables, CTEs, and a derived table or CTE joined on the right for subscripts and comparisons (CHECK constraints and generated columns evaluate through the stateless evaluator without a table schema, so array subscripts there keep the element text); UNIQUE indexes and primary keys still use the canonical raw text, so 1.10 and 1.1 are distinct index keys even though they compare equal"},
 			RowID:    Feature{Supported: true, Status: "auto_assigned", Notes: "rowid/oid/_rowid_ are BIGINT over the wire; monotonic when the engine assigns the rowid, stable across UPDATE, not monotonic for client-assigned descending INTEGER PRIMARY KEY values"},
 		},
 

+ 253 - 33
pkg/executor/arrays.go

@@ -2,6 +2,7 @@ package executor
 
 import (
 	"fmt"
+	"math/big"
 	"strconv"
 	"strings"
 
@@ -265,10 +266,10 @@ func canonicalArrayElement(element *string, kind arrayElementKind, typeName stri
 		canonical := strconv.FormatFloat(f, 'g', -1, 64)
 		return &canonical, nil
 	case arrayElementNumeric:
-		if !isNumericArrayElement(raw) {
+		canonical, ok := normalizeNumericArrayElement(raw)
+		if !ok {
 			return nil, pgerror.InvalidText("invalid input syntax for type %s: %q", typeName, raw)
 		}
-		canonical := raw
 		return &canonical, nil
 	case arrayElementBoolean:
 		value, ok := parseArrayBoolean(raw)
@@ -324,11 +325,12 @@ func arrayElementValue(element *string, elementType string) (interface{}, error)
 		}
 		return f, nil
 	case arrayElementNumeric:
-		if !isNumericArrayElement(raw) {
+		canonical, ok := normalizeNumericArrayElement(raw)
+		if !ok {
 			return nil, pgerror.InvalidText("invalid input syntax for type numeric: %q", raw)
 		}
 		// Return the text so a value above 2^53 keeps every digit.
-		return raw, nil
+		return canonical, nil
 	case arrayElementBoolean:
 		value, ok := parseArrayBoolean(raw)
 		if !ok {
@@ -340,15 +342,137 @@ func arrayElementValue(element *string, elementType string) (interface{}, error)
 	}
 }
 
-// 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 {
+// normalizeNumericArrayElement validates SQL NUMERIC syntax and returns the
+// canonical spelling: surrounding whitespace trimmed and a leading + removed.
+// Go's ParseFloat is not used because it also accepts hex floats, NaN, and Inf,
+// which NUMERIC has no representation for here.
+func normalizeNumericArrayElement(raw string) (string, bool) {
 	text := strings.TrimSpace(raw)
 	if text == "" {
-		return false
+		return "", false
+	}
+	if text[0] == '+' {
+		text = text[1:]
+	}
+	if text == "" {
+		return "", false
+	}
+	i := 0
+	if text[i] == '-' {
+		i++
+	}
+	digits := 0
+	for i < len(text) && text[i] >= '0' && text[i] <= '9' {
+		i++
+		digits++
+	}
+	if i < len(text) && text[i] == '.' {
+		i++
+		for i < len(text) && text[i] >= '0' && text[i] <= '9' {
+			i++
+			digits++
+		}
 	}
-	_, err := strconv.ParseFloat(text, 64)
-	return err == nil
+	if digits == 0 {
+		return "", false
+	}
+	if i < len(text) && (text[i] == 'e' || text[i] == 'E') {
+		i++
+		if i < len(text) && (text[i] == '+' || text[i] == '-') {
+			i++
+		}
+		exponentDigits := 0
+		for i < len(text) && text[i] >= '0' && text[i] <= '9' {
+			i++
+			exponentDigits++
+		}
+		if exponentDigits == 0 {
+			return "", false
+		}
+	}
+	if i != len(text) {
+		return "", false
+	}
+	return text, true
+}
+
+// numericValueRat converts an evaluated value to an exact rational. Strings and
+// floats use their decimal spelling, so a column value "1.10" and the float
+// literal 1.1 both compare as 11/10 rather than through binary rounding.
+func numericValueRat(value interface{}) (*big.Rat, bool) {
+	var text string
+	switch v := value.(type) {
+	case string:
+		normalized, ok := normalizeNumericArrayElement(v)
+		if !ok {
+			return nil, false
+		}
+		text = normalized
+	case int64:
+		return new(big.Rat).SetInt64(v), true
+	case int:
+		return new(big.Rat).SetInt64(int64(v)), true
+	case float64:
+		text = strconv.FormatFloat(v, 'g', -1, 64)
+	default:
+		return nil, false
+	}
+	rat, ok := new(big.Rat).SetString(text)
+	return rat, ok
+}
+
+// compareNumericValues compares two values exactly when both are numeric.
+func compareNumericValues(left, right interface{}) (int, bool) {
+	leftRat, ok := numericValueRat(left)
+	if !ok {
+		return 0, false
+	}
+	rightRat, ok := numericValueRat(right)
+	if !ok {
+		return 0, false
+	}
+	return leftRat.Cmp(rightRat), true
+}
+
+// numericArrayTextEqual reports whether two canonical NUMERIC array texts are
+// equal element-wise, with NULL elements equal to each other. Other array types
+// compare by their canonical bytes.
+func numericArrayTextEqual(left, right, typeName string) (bool, error) {
+	kind, err := arrayElementKindOf(typeName)
+	if err != nil {
+		return false, err
+	}
+	if kind != arrayElementNumeric {
+		return left == right, nil
+	}
+	leftElements, err := parseArrayLiteral(left)
+	if err != nil {
+		return false, err
+	}
+	rightElements, err := parseArrayLiteral(right)
+	if err != nil {
+		return false, err
+	}
+	if len(leftElements) != len(rightElements) {
+		return false, nil
+	}
+	for i := range leftElements {
+		leftElement, rightElement := leftElements[i], rightElements[i]
+		if leftElement == nil || rightElement == nil {
+			if leftElement != nil || rightElement != nil {
+				return false, nil
+			}
+			continue
+		}
+		cmp, ok := compareNumericValues(*leftElement, *rightElement)
+		if !ok {
+			return false, pgerror.InvalidText("invalid input syntax for type numeric: %q", *leftElement)
+		}
+		if cmp != 0 {
+			return false, nil
+		}
+	}
+	return true, nil
 }
 
 // arrayValueElement renders one evaluated expression value as a canonical array
@@ -452,6 +576,84 @@ func (e *Executor) parsedArray(expr parser.Expr, value interface{}) ([]*string,
 	return elements, err
 }
 
+// isNumericExprType reports whether an expression's declared type is NUMERIC or
+// DECIMAL, which compares exactly instead of through float64. CAST(... AS
+// NUMERIC) is included even though its result metadata stays unchanged: the
+// cast leaves the value's carrier alone, but comparisons still mean decimal.
+func (e *Executor) isNumericExprType(expr parser.Expr) bool {
+	switch x := expr.(type) {
+	case nil:
+		return false
+	case *parser.ParenExpr:
+		return e.isNumericExprType(x.Expr)
+	case *parser.CastExpr:
+		switch strings.ToUpper(strings.TrimSpace(x.Type.Name)) {
+		case "NUMERIC", "DECIMAL":
+			return true
+		}
+		return false
+	}
+	switch strings.ToUpper(strings.TrimSpace(inferExprType(expr, e.resolveColumnType))) {
+	case "NUMERIC", "DECIMAL":
+		return true
+	default:
+		return false
+	}
+}
+
+// comparisonOperatorResult applies a comparison operator to a three-way result.
+func comparisonOperatorResult(op lexer.TokenType, cmp int) (bool, error) {
+	switch op {
+	case lexer.TokenEq:
+		return cmp == 0, nil
+	case lexer.TokenNeq:
+		return cmp != 0, nil
+	case lexer.TokenLt:
+		return cmp < 0, nil
+	case lexer.TokenLte:
+		return cmp <= 0, nil
+	case lexer.TokenGt:
+		return cmp > 0, nil
+	case lexer.TokenGte:
+		return cmp >= 0, nil
+	default:
+		return false, fmt.Errorf("unsupported comparison operator: %v", op)
+	}
+}
+
+// arrayEqualityType reports the array type used for a whole-array equality
+// comparison. The left side wins when both are arrays.
+func (e *Executor) arrayEqualityType(leftExpr, rightExpr parser.Expr) (string, bool) {
+	if typeName, ok := e.arrayTypeOf(leftExpr); ok {
+		return typeName, true
+	}
+	return e.arrayTypeOf(rightExpr)
+}
+
+// compareExprValues applies a comparison operator to two evaluated values. A
+// NUMERIC-typed operand compares exactly (including element-wise numeric array
+// equality); everything else keeps the engine's existing comparison rules.
+func (e *Executor) compareExprValues(op lexer.TokenType, leftExpr, rightExpr parser.Expr, left, right interface{}) (bool, error) {
+	if op == lexer.TokenEq || op == lexer.TokenNeq {
+		if typeName, ok := e.arrayEqualityType(leftExpr, rightExpr); ok {
+			equal, err := numericArrayTextEqual(toString(left), toString(right), typeName)
+			if err != nil {
+				return false, err
+			}
+			if op == lexer.TokenNeq {
+				return !equal, nil
+			}
+			return equal, nil
+		}
+	}
+	if e.isNumericExprType(leftExpr) || e.isNumericExprType(rightExpr) {
+		if cmp, ok := compareNumericValues(left, right); ok {
+			return comparisonOperatorResult(op, cmp)
+		}
+	}
+	return comparisonOperatorResult(op, compare(left, right))
+}
+
 // arrayElementTypeOf reports the element type of an expression whose value is an
 // array, resolved against the declared column types of the query currently
 // executing. An unknown type (a derived table, a bare literal) reports false so
@@ -549,13 +751,14 @@ func (e *Executor) evalAnyAllExpr(expr *parser.AnyAllExpr, row storage.Row) (int
 	if err != nil {
 		return nil, fmt.Errorf("ANY/ALL requires an array operand: %w", err)
 	}
-	return evalQuantifiedComparison(expr.Op, expr.All, left, elements)
+	elementType, _ := e.arrayElementTypeOf(expr.Array)
+	return evalQuantifiedComparison(expr.Op, expr.All, left, elements, elementType)
 }
 
 // evalQuantifiedComparison applies a comparison to each array element. The
 // empty array is decided before the left operand: ANY over no elements is
 // false and ALL over no elements is true, even when the left operand is NULL.
-func evalQuantifiedComparison(op lexer.TokenType, all bool, left interface{}, elements []*string) (interface{}, error) {
+func evalQuantifiedComparison(op lexer.TokenType, all bool, left interface{}, elements []*string, elementType string) (interface{}, error) {
 	if len(elements) == 0 {
 		return all, nil
 	}
@@ -568,7 +771,7 @@ func evalQuantifiedComparison(op lexer.TokenType, all bool, left interface{}, el
 			sawNull = true
 			continue
 		}
-		matched, err := compareArrayElement(op, left, *element)
+		matched, err := compareArrayElement(op, left, *element, elementType)
 		if err != nil {
 			return nil, err
 		}
@@ -589,7 +792,14 @@ func evalQuantifiedComparison(op lexer.TokenType, all bool, left interface{}, el
 // 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) {
+func compareArrayElement(op lexer.TokenType, left interface{}, element, elementType string) (bool, error) {
+	if kind, err := arrayElementKindForName(elementType); err == nil && kind == arrayElementNumeric {
+		cmp, ok := compareNumericValues(left, element)
+		if !ok {
+			return false, pgerror.InvalidText("invalid input syntax for type numeric: %q", element)
+		}
+		return comparisonOperatorResult(op, cmp)
+	}
 	var right interface{} = element
 	if _, isBool := left.(bool); isBool {
 		value, ok := parseArrayBoolean(element)
@@ -598,23 +808,7 @@ func compareArrayElement(op lexer.TokenType, left interface{}, element string) (
 		}
 		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)
-	}
+	return comparisonOperatorResult(op, compare(left, right))
 }
 
 // evalArrayLength implements array_length(array, dimension). Only dimension 1 is
@@ -657,8 +851,19 @@ func (e *Executor) arrayTypeOf(expr parser.Expr) (string, bool) {
 	return typeName, true
 }
 
+// arrayCanonicalEntry memoizes one canonicalized array comparison operand for
+// the current statement, validated by the source text and target type.
+type arrayCanonicalEntry struct {
+	text      string
+	typeName  string
+	canonical string
+	err       error
+}
+
 // canonicalArrayValue canonicalizes a value against the array type of expr. It
-// is a no-op for non-array expressions, NULL, and non-string values.
+// is a no-op for non-array expressions, NULL, and non-string values. The result
+// is memoized per AST node and source text, so a constant literal on one side of
+// a scan predicate is canonicalized once per statement.
 func (e *Executor) canonicalArrayValue(arrayExpr parser.Expr, value interface{}) (interface{}, error) {
 	typeName, ok := e.arrayTypeOf(arrayExpr)
 	if !ok || value == nil {
@@ -668,7 +873,22 @@ func (e *Executor) canonicalArrayValue(arrayExpr parser.Expr, value interface{})
 	if !isText {
 		return value, nil
 	}
-	return canonicalArrayText(text, typeName)
+	if e.arrayCanonicalCache != nil && arrayExpr != nil {
+		if entry, cached := e.arrayCanonicalCache[arrayExpr]; cached && entry.text == text && entry.typeName == typeName {
+			if entry.err != nil {
+				return nil, entry.err
+			}
+			return entry.canonical, nil
+		}
+	}
+	canonical, err := canonicalArrayText(text, typeName)
+	if e.arrayCanonicalCache != nil && arrayExpr != nil {
+		e.arrayCanonicalCache[arrayExpr] = &arrayCanonicalEntry{text: text, typeName: typeName, canonical: canonical, err: err}
+	}
+	if err != nil {
+		return nil, err
+	}
+	return canonical, nil
 }
 
 // canonicalizeArrayComparison canonicalizes the string operand of a comparison

+ 256 - 5
pkg/executor/arrays_test.go

@@ -4,6 +4,7 @@ import (
 	"errors"
 	"strings"
 	"testing"
+	"time"
 
 	"github.com/danfragoso/pizzasql-next/pkg/analyzer"
 	"github.com/danfragoso/pizzasql-next/pkg/lexer"
@@ -519,13 +520,68 @@ func TestNumericArrayPrecision(t *testing.T) {
 		t.Fatalf("numeric cast type = %q, want NUMERIC[]", res.ColumnTypes[0])
 	}
 
-	// Trailing zeros and big values compare against themselves exactly.
+	// Whole-array equality is numeric-aware for NUMERIC[]: spelling differences
+	// (1.10 vs 1.1, 1e+3 vs 1000) compare equal, while a changed digit does not.
+	cases := []struct {
+		sql  string
+		want interface{}
+	}{
+		{`SELECT count(*) FROM t WHERE amounts = '{12345678901234567890,1.10,1e+3}'`, int64(1)},
+		{`SELECT count(*) FROM t WHERE amounts = '{12345678901234567890,1.1,1000}'`, int64(1)},
+		{`SELECT count(*) FROM t WHERE amounts = '{12345678901234567891,1.10,1e+3}'`, int64(0)},
+		{`SELECT count(*) FROM t WHERE amounts <> '{12345678901234567890,1.1,1000}'`, int64(0)},
+		{`SELECT amounts = '{12345678901234567890,1.1,1000}' FROM t`, true},
+		{`SELECT amounts IS DISTINCT FROM '{12345678901234567890,1.1,1000}' FROM t`, false},
+		{`SELECT '{1.10}'::NUMERIC[] = '{1.1}'::NUMERIC[]`, true},
+		{`SELECT '{1.10}'::NUMERIC[] <> '{1.1}'::NUMERIC[]`, false},
+		{`SELECT '{1.10,2.50}'::NUMERIC[] = '{1.1,2.5}'::NUMERIC[]`, true},
+		// Element comparison is exact too, so a float64 rounding cannot make two
+		// adjacent 20-digit values equal.
+		{`SELECT count(*) FROM t WHERE amounts[1] = '12345678901234567890'`, int64(1)},
+		{`SELECT count(*) FROM t WHERE amounts[1] = '12345678901234567891'`, int64(0)},
+		{`SELECT count(*) FROM t WHERE amounts[2] = '1.1'`, int64(1)},
+		{`SELECT count(*) FROM t WHERE amounts[2] = '1.10'`, int64(1)},
+		{`SELECT count(*) FROM t WHERE amounts[2] = ANY('{1.1}')`, int64(1)},
+		{`SELECT count(*) FROM t WHERE amounts[2] = ANY('{1.11}')`, int64(0)},
+		{`SELECT count(*) FROM t WHERE amounts IN ('{12345678901234567890,1.1,1000}')`, int64(1)},
+	}
+	for _, tc := range cases {
+		res := execMust(t, e, tc.sql)
+		if got := res.Rows[0][0]; got != tc.want {
+			t.Errorf("%s = %#v, want %#v", tc.sql, got, tc.want)
+		}
+	}
+}
+
+// NUMERIC element text is trimmed and a leading + is normalized; non-decimal
+// spellings ParseFloat would accept (hex, NaN, Inf) are rejected.
+func TestNumericArrayValidation(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+
+	for _, tc := range []struct{ sql, want string }{
+		{`SELECT '{" 1.10 "}'::NUMERIC[]`, "{1.10}"},
+		{`SELECT '{+1.10}'::NUMERIC[]`, "{1.10}"},
+		{`SELECT '{-1.10,+.5}'::NUMERIC[]`, "{-1.10,.5}"},
+		{`SELECT '{1e+3,2E-2}'::NUMERIC[]`, "{1e+3,2E-2}"},
+	} {
+		res := execMust(t, e, tc.sql)
+		if got := res.Rows[0][0]; got != tc.want {
+			t.Errorf("%s = %v, want %s", tc.sql, got, tc.want)
+		}
+	}
+
 	for _, sql := range []string{
-		`SELECT count(*) FROM t WHERE amounts = '{12345678901234567890,1.10,1e+3}'`,
-		`SELECT count(*) FROM t WHERE amounts @> '{1}'`,
+		`SELECT '{0x1p4}'::NUMERIC[]`,
+		`SELECT '{NaN}'::NUMERIC[]`,
+		`SELECT '{Inf}'::NUMERIC[]`,
+		`SELECT '{Infinity}'::NUMERIC[]`,
+		`SELECT '{1.2.3}'::NUMERIC[]`,
+		`SELECT '{1e}'::NUMERIC[]`,
+		`SELECT '{+}'::NUMERIC[]`,
 	} {
-		if _, err := execSQL(e, sql); err != nil && sql[:20] != "SELECT count(*) FROM" {
-			t.Fatal(err)
+		if _, err := execSQL(e, sql); !errors.Is(err, pgerror.ErrInvalidText) {
+			t.Errorf("%s error = %v, want ErrInvalidText", sql, err)
 		}
 	}
 }
@@ -718,3 +774,198 @@ func TestArrayOperandMemoizesConstantExpressions(t *testing.T) {
 		t.Fatalf("column operand evaluated %d times, want 3", calls)
 	}
 }
+
+// Column typing survives a derived table: the outer statement must decode
+// boolean subscripts instead of treating "t"/"f" as text.
+func TestDerivedTableArrayTyping(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, `CREATE TABLE t (id INTEGER PRIMARY KEY, flags BOOLEAN[], tags TEXT[])`)
+	execMust(t, e, `INSERT INTO t VALUES (1, '{t,f}', '{a,b}'), (2, '{f,t}', '{c}')`)
+
+	cases := []struct {
+		sql  string
+		want interface{}
+	}{
+		{`SELECT count(*) FROM (SELECT flags FROM t) x WHERE flags[1]`, int64(1)},
+		{`SELECT count(*) FROM (SELECT flags FROM t) x WHERE NOT flags[1]`, int64(1)},
+		{`SELECT count(*) FROM (SELECT flags FROM t) x WHERE flags[1] = TRUE`, int64(1)},
+		{`SELECT count(*) FROM (SELECT flags FROM t) x WHERE flags[1] = ANY('{true}')`, int64(1)},
+		{`SELECT count(*) FROM (SELECT tags FROM t) x WHERE tags = '{a, b}'`, int64(1)},
+		{`SELECT count(*) FROM (SELECT tags FROM t) x WHERE tags = '{"a","b"}'`, int64(1)},
+	}
+	for _, tc := range cases {
+		res := execMust(t, e, tc.sql)
+		if got := res.Rows[0][0]; got != tc.want {
+			t.Errorf("%s = %#v, want %#v", tc.sql, got, tc.want)
+		}
+	}
+}
+
+// Column typing survives a CTE, including NOT over a boolean element.
+func TestCTEArrayTyping(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, `CREATE TABLE t (id INTEGER PRIMARY KEY, flags BOOLEAN[], nums INTEGER[])`)
+	execMust(t, e, `INSERT INTO t VALUES (1, '{t,f}', '{10,20}'), (2, '{f,t}', '{30,40}')`)
+
+	cases := []struct {
+		sql  string
+		want interface{}
+	}{
+		{`WITH c AS (SELECT flags FROM t) SELECT count(*) FROM c WHERE flags[1]`, int64(1)},
+		{`WITH c AS (SELECT flags FROM t) SELECT count(*) FROM c WHERE NOT flags[1]`, int64(1)},
+		{`WITH c AS (SELECT flags FROM t) SELECT count(*) FROM c WHERE flags[1] = ANY('{true}')`, int64(1)},
+		{`WITH c AS (SELECT nums FROM t) SELECT count(*) FROM c WHERE nums[1] = 10`, int64(1)},
+		{`WITH c AS (SELECT nums FROM t) SELECT count(*) FROM c WHERE nums = '{10,20}'`, int64(1)},
+		{`WITH c AS (SELECT nums FROM t) SELECT count(*) FROM c WHERE nums = '{10, 20}'`, int64(1)},
+	}
+	for _, tc := range cases {
+		res := execMust(t, e, tc.sql)
+		if got := res.Rows[0][0]; got != tc.want {
+			t.Errorf("%s = %#v, want %#v", tc.sql, got, tc.want)
+		}
+	}
+}
+
+// A materialized CTE shadows a real table with the same name. Its own column
+// types must win; reading the shadowed table's schema would decode INTEGER[]
+// elements as text or report a spurious invalid-array error.
+func TestCTEShadowingRealTableUsesCTETypes(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, `CREATE TABLE shadow (tags INTEGER[], flags TEXT[])`)
+	execMust(t, e, `INSERT INTO shadow VALUES ('{1}', '{"not a bool"}')`)
+	execMust(t, e, `CREATE TABLE docs (id INTEGER PRIMARY KEY, tags TEXT[], flags BOOLEAN[])`)
+	execMust(t, e, `INSERT INTO docs VALUES (1, '{a,b}', '{f}')`)
+
+	// Boolean NOT only flips when the CTE's BOOLEAN[] type is used.
+	res := execMust(t, e, `WITH shadow AS (SELECT flags FROM docs) SELECT count(*) FROM shadow WHERE NOT flags[1]`)
+	if res.Rows[0][0] != int64(1) {
+		t.Fatalf("CTE boolean shadow count = %v, want 1", res.Rows[0][0])
+	}
+	// TEXT[] element access accepts 'a'; the shadowed INTEGER[] type would fail.
+	res = execMust(t, e, `WITH shadow AS (SELECT tags FROM docs) SELECT count(*) FROM shadow WHERE tags[1] = 'a'`)
+	if res.Rows[0][0] != int64(1) {
+		t.Fatalf("CTE text shadow count = %v, want 1", res.Rows[0][0])
+	}
+	// Comparison canonicalization also uses the CTE's type.
+	res = execMust(t, e, `WITH shadow AS (SELECT tags FROM docs) SELECT count(*) FROM shadow WHERE tags = '{a, b}'`)
+	if res.Rows[0][0] != int64(1) {
+		t.Fatalf("CTE comparison shadow count = %v, want 1", res.Rows[0][0])
+	}
+}
+
+// The deadlock fix must hold for a locked in-transaction scan that evaluates an
+// array subscript: a SELECT over the table followed by an UPDATE must not
+// re-enter the session lock.
+func TestArraySubscriptInTransactionScan(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, `CREATE TABLE t (id INTEGER PRIMARY KEY, flags BOOLEAN[], n INTEGER)`)
+	execMust(t, e, `INSERT INTO t VALUES (1, '{t,f}', 0), (2, '{f,t}', 0)`)
+
+	execMust(t, e, `BEGIN`)
+	runWithTimeout(t, 5*time.Second, func() {
+		res := execMust(t, e, `SELECT count(*) FROM t WHERE flags[1]`)
+		if res.Rows[0][0] != int64(1) {
+			t.Errorf("in-transaction count = %v, want 1", res.Rows[0][0])
+		}
+	})
+	runWithTimeout(t, 5*time.Second, func() {
+		execMust(t, e, `UPDATE t SET n = n + 1 WHERE flags[1]`)
+	})
+	execMust(t, e, `COMMIT`)
+
+	res := execMust(t, e, `SELECT n FROM t ORDER BY id`)
+	if res.Rows[0][0] != int64(1) || res.Rows[1][0] != int64(0) {
+		t.Fatalf("post-commit values = %v", res.Rows)
+	}
+}
+
+// canonicalArrayValue memoizes a constant comparison literal per statement and
+// still re-canonicalizes changed text or a different target type.
+func TestCanonicalArrayValueMemoizes(t *testing.T) {
+	e := &Executor{arrayCanonicalCache: make(map[parser.Expr]*arrayCanonicalEntry)}
+	e.pushTypeScope(func(_, column string) (string, bool) {
+		if strings.EqualFold(column, "tags") {
+			return "TEXT[]", true
+		}
+		return "", false
+	})
+	expr := &parser.ColumnRef{Column: "tags"}
+
+	first, err := e.canonicalArrayValue(expr, "{a, b}")
+	if err != nil || first != "{a,b}" {
+		t.Fatalf("canonical = %v, %v", first, err)
+	}
+	if _, ok := e.arrayCanonicalCache[expr]; !ok {
+		t.Fatal("canonical literal was not cached")
+	}
+	second, err := e.canonicalArrayValue(expr, "{a, b}")
+	if err != nil || second != "{a,b}" {
+		t.Fatalf("cached canonical = %v, %v", second, err)
+	}
+	changed, err := e.canonicalArrayValue(expr, "{c}")
+	if err != nil || changed != "{c}" {
+		t.Fatalf("changed canonical = %v, %v", changed, err)
+	}
+
+	if _, err := e.canonicalArrayValue(expr, "not-an-array"); !errors.Is(err, pgerror.ErrInvalidText) {
+		t.Fatalf("malformed literal error = %v, want ErrInvalidText", err)
+	}
+	if _, err := e.canonicalArrayValue(expr, "not-an-array"); !errors.Is(err, pgerror.ErrInvalidText) {
+		t.Fatalf("cached malformed literal error = %v, want ErrInvalidText", err)
+	}
+}
+
+// A derived table or CTE materialized on the right side of a JOIN also keeps
+// its column types for the outer WHERE.
+func TestJoinedDerivedAndCTEArrayTyping(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, `CREATE TABLE t (id INTEGER PRIMARY KEY, flags BOOLEAN[])`)
+	execMust(t, e, `INSERT INTO t VALUES (1, '{t,f}'), (2, '{f,t}')`)
+	execMust(t, e, `CREATE TABLE u (id INTEGER PRIMARY KEY, flags BOOLEAN[], tags TEXT[])`)
+	execMust(t, e, `INSERT INTO u VALUES (1, '{f,t}', '{a,b}'), (2, '{t,f}', '{c}')`)
+
+	cases := []struct {
+		sql  string
+		want interface{}
+	}{
+		{`SELECT count(*) FROM t JOIN (SELECT id, flags FROM u) x ON x.id = t.id WHERE NOT x.flags[1]`, int64(1)},
+		{`SELECT count(*) FROM t JOIN (SELECT id, flags FROM u) x ON x.id = t.id WHERE x.flags[1] = ANY('{true}')`, int64(1)},
+		{`SELECT count(*) FROM t JOIN (SELECT id, tags FROM u) x ON x.id = t.id WHERE x.tags = '{a, b}'`, int64(1)},
+		{`WITH x AS (SELECT id, flags FROM u) SELECT count(*) FROM t JOIN x ON x.id = t.id WHERE NOT x.flags[1]`, int64(1)},
+		{`WITH x AS (SELECT id, tags FROM u) SELECT count(*) FROM t JOIN x ON x.id = t.id WHERE x.tags = '{a, b}'`, int64(1)},
+	}
+	for _, tc := range cases {
+		res := execMust(t, e, tc.sql)
+		if got := res.Rows[0][0]; got != tc.want {
+			t.Errorf("%s = %#v, want %#v", tc.sql, got, tc.want)
+		}
+	}
+}
+
+// A view expands to a derived table, so it keeps the same typing guarantees.
+func TestViewArrayTyping(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, `CREATE TABLE t (id INTEGER PRIMARY KEY, flags BOOLEAN[], tags TEXT[])`)
+	execMust(t, e, `INSERT INTO t VALUES (1, '{t,f}', '{a,b}'), (2, '{f,t}', '{c}')`)
+	execMust(t, e, `CREATE VIEW v AS SELECT flags, tags FROM t`)
+
+	for _, tc := range []struct {
+		sql  string
+		want interface{}
+	}{
+		{`SELECT count(*) FROM v WHERE NOT flags[1]`, int64(1)},
+		{`SELECT count(*) FROM v WHERE flags[1] = ANY('{true}')`, int64(1)},
+		{`SELECT count(*) FROM v WHERE tags = '{a, b}'`, int64(1)},
+	} {
+		res := execMust(t, e, tc.sql)
+		if got := res.Rows[0][0]; got != tc.want {
+			t.Errorf("%s = %#v, want %#v", tc.sql, got, tc.want)
+		}
+	}
+}

+ 15 - 8
pkg/executor/cte.go

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

+ 119 - 17
pkg/executor/executor.go

@@ -81,11 +81,21 @@ type Executor struct {
 	// text for every row of a scan.
 	arrayValueCache map[parser.Expr]interface{}
 
+	// Per-query cache of canonicalized array comparison literals, keyed by AST
+	// pointer and validated by the source text, so `tags = '{a, b}'` parses the
+	// literal once per statement rather than once per row.
+	arrayCanonicalCache map[parser.Expr]*arrayCanonicalEntry
+
 	// typeScopes holds the column-type resolvers of the SELECT statements being
 	// evaluated, innermost last. Value-time consumers (array subscripts) use
 	// them to recover a column's declared type.
 	typeScopes []columnTypeResolver
 
+	// joinTypeScopes holds the per-statement records of right-hand relations
+	// materialized while executing joins, so their column types are available
+	// after the join rows are built.
+	joinTypeScopes []*joinTypeScope
+
 	// In-memory view registry: view name (lowercase) → SELECT AST.
 	views map[string]*parser.SelectStmt
 
@@ -226,11 +236,13 @@ func (e *Executor) Execute(stmt parser.Statement) (*Result, error) {
 	e.correlatedAggCache = make(map[*parser.SelectStmt]*correlatedAggCache)
 	e.arrayCache = make(map[parser.Expr]*arrayParseEntry)
 	e.arrayValueCache = make(map[parser.Expr]interface{})
+	e.arrayCanonicalCache = make(map[parser.Expr]*arrayCanonicalEntry)
 	defer func() {
 		e.subqueryCache = nil
 		e.correlatedAggCache = nil
 		e.arrayCache = nil
 		e.arrayValueCache = nil
+		e.arrayCanonicalCache = nil
 	}()
 
 	// PRAGMA doesn't need analysis. SQLite catalog-introspection pragmas
@@ -421,8 +433,18 @@ func isCountStarSingleTable(stmt *parser.SelectStmt) bool {
 
 // executeSelect executes a SELECT statement (or compound SELECT).
 func (e *Executor) executeSelect(stmt *parser.SelectStmt) (*Result, error) {
+	// The join scope is pushed below the snapshot so a real table's unqualified
+	// column keeps precedence; it answers for right-side derived tables and
+	// CTEs that the snapshot cannot read without materializing them again.
+	joinScope := newJoinTypeScope()
+	e.joinTypeScopes = append(e.joinTypeScopes, joinScope)
+	e.pushTypeScope(joinScope.resolve)
 	e.pushTypeScope(e.snapshotTypeResolver(stmt.From))
-	defer e.popTypeScope()
+	defer func() {
+		e.popTypeScope()
+		e.popTypeScope()
+		e.joinTypeScopes = e.joinTypeScopes[:len(e.joinTypeScopes)-1]
+	}()
 
 	if len(stmt.With) > 0 {
 		return e.executeWith(stmt)
@@ -480,7 +502,11 @@ func (e *Executor) executeSelect(stmt *parser.SelectStmt) (*Result, error) {
 
 	// A materialized CTE is served from memory, not from storage.
 	if cte, ok := e.cteTableFor(tableName); ok {
-		return e.executeSelectOnMaterialized(stmt, cte.columns, cteRowsToValues(cte))
+		qualifier := stmt.From[0].Alias
+		if qualifier == "" {
+			qualifier = stmt.From[0].Name
+		}
+		return e.executeSelectOnMaterializedTyped(stmt, cte.columns, cte.types, cteRowsToValues(cte), qualifier)
 	}
 
 	// Transparently expand view references as derived-table subqueries.
@@ -1592,6 +1618,8 @@ func (e *Executor) executeSelectFromTableFunction(stmt *parser.SelectStmt) (*Res
 }
 
 // executeSelectFromSubquery executes a SELECT with a subquery in FROM clause.
+// The derived table's column types are carried into the outer statement so
+// array subscripts and comparisons keep decoding elements.
 func (e *Executor) executeSelectFromSubquery(stmt *parser.SelectStmt) (*Result, error) {
 	// Execute the subquery to get the derived table
 	subqueryResult, err := e.executeSelect(stmt.From[0].Subquery)
@@ -1599,20 +1627,26 @@ func (e *Executor) executeSelectFromSubquery(stmt *parser.SelectStmt) (*Result,
 		return nil, fmt.Errorf("subquery error: %w", err)
 	}
 
-	return e.executeSelectOnMaterialized(stmt, subqueryResult.Columns, subqueryResult.Rows)
+	qualifier := stmt.From[0].Alias
+	if qualifier == "" {
+		qualifier = stmt.From[0].Name
+	}
+	return e.executeSelectOnMaterializedTyped(stmt, subqueryResult.Columns, subqueryResult.ColumnTypes, subqueryResult.Rows, qualifier)
 }
 
 func (e *Executor) executeSelectFromSubqueries(stmt *parser.SelectStmt) (*Result, error) {
 	rows := []storage.Row{{}}
-	var columns, starKeys []string
+	var columns, starKeys, types, qualifiers []string
 	for _, ref := range stmt.From {
 		res, err := e.executeSelect(ref.Subquery)
 		if err != nil {
 			return nil, fmt.Errorf("subquery error: %w", err)
 		}
 		columns = append(columns, res.Columns...)
-		for _, column := range res.Columns {
+		for i, column := range res.Columns {
 			starKeys = append(starKeys, ref.Alias+"."+column)
+			qualifiers = append(qualifiers, ref.Alias)
+			types = append(types, typeAt(res.ColumnTypes, i))
 		}
 
 		right := make([]storage.Row, 0, len(res.Rows))
@@ -1641,13 +1675,38 @@ func (e *Executor) executeSelectFromSubqueries(stmt *parser.SelectStmt) (*Result
 		}
 		rows = joined
 	}
+	e.pushTypeScope(materializedTypeResolver(columns, types, qualifiers))
+	defer e.popTypeScope()
 	return e.executeSelectOnMaterializedRows(stmt, columns, starKeys, rows)
 }
 
+// typeAt returns the type at index i, or the empty string when the position has
+// no recorded type (which the resolver reports as known-but-untyped).
+func typeAt(types []string, i int) string {
+	if i < len(types) {
+		return types[i]
+	}
+	return ""
+}
+
 // executeSelectOnMaterialized runs a SELECT whose FROM[0] is already
-// materialized as (columns, rowValues). It is shared by derived tables and
-// materialized CTEs.
+// materialized as (columns, rowValues) without column-type metadata.
 func (e *Executor) executeSelectOnMaterialized(stmt *parser.SelectStmt, columns []string, rowValues [][]interface{}) (*Result, error) {
+	return e.executeSelectOnMaterializedTyped(stmt, columns, nil, rowValues, "")
+}
+
+// executeSelectOnMaterializedTyped runs the same materialized SELECT with the
+// relation's column types exposed to value-time consumers such as array
+// subscripts. qualifier names the relation (alias or name) for qualified column
+// references; one qualifier applies to every column.
+func (e *Executor) executeSelectOnMaterializedTyped(stmt *parser.SelectStmt, columns, columnTypes []string, rowValues [][]interface{}, qualifier string) (*Result, error) {
+	qualifiers := make([]string, len(columns))
+	for i := range qualifiers {
+		qualifiers[i] = qualifier
+	}
+	e.pushTypeScope(materializedTypeResolver(columns, columnTypes, qualifiers))
+	defer e.popTypeScope()
+
 	derivedRows := make([]storage.Row, 0, len(rowValues))
 	for _, values := range rowValues {
 		row := make(storage.Row, len(columns))
@@ -1666,7 +1725,7 @@ func (e *Executor) executeSelectOnMaterialized(stmt *parser.SelectStmt, columns
 // every SELECT * column, including duplicate column names.
 func (e *Executor) executeSelectOnMaterializedCTEs(stmt *parser.SelectStmt) (*Result, error) {
 	rows := []storage.Row{{}}
-	var columns, starKeys []string
+	var columns, starKeys, types, qualifiers []string
 	for _, ref := range stmt.From {
 		cte, _ := e.cteTableFor(ref.Name)
 		qualifier := ref.Alias
@@ -1674,8 +1733,10 @@ func (e *Executor) executeSelectOnMaterializedCTEs(stmt *parser.SelectStmt) (*Re
 			qualifier = ref.Name
 		}
 		columns = append(columns, cte.columns...)
-		for _, column := range cte.columns {
+		for i, column := range cte.columns {
 			starKeys = append(starKeys, qualifier+"."+column)
+			qualifiers = append(qualifiers, qualifier)
+			types = append(types, typeAt(cte.types, i))
 		}
 
 		right := cloneRows(cte.rows)
@@ -1697,6 +1758,8 @@ func (e *Executor) executeSelectOnMaterializedCTEs(stmt *parser.SelectStmt) (*Re
 		}
 		rows = joined
 	}
+	e.pushTypeScope(materializedTypeResolver(columns, types, qualifiers))
+	defer e.popTypeScope()
 	return e.executeSelectOnMaterializedRows(stmt, columns, starKeys, rows)
 }
 
@@ -2200,14 +2263,17 @@ func (e *Executor) executeJoinsWithMode(tableRef parser.TableRef, leftRows []sto
 	var err error
 	switch {
 	case rightTableRef.Subquery != nil:
-		rightRows, rightSchema, err = e.materializeJoinSubquery(rightTableRef)
+		var columns, types []string
+		rightRows, rightSchema, columns, types, err = e.materializeJoinSubquery(rightTableRef)
 		if err != nil {
 			return nil, err
 		}
+		e.recordJoinColumns(rightAlias, columns, types)
 	case e.cteTableExists(rightTable):
 		cte, _ := e.cteTableFor(rightTable)
 		rightRows = cloneRows(cte.rows)
 		rightSchema = schemaFromColumns(cte.columns)
+		e.recordJoinColumns(rightAlias, cte.columns, cte.types)
 	default:
 		rightSchema, err = e.session.GetSchema(rightTable)
 		if err != nil {
@@ -2335,14 +2401,21 @@ func (e *Executor) executeJoin(tableRef parser.TableRef, leftRows []storage.Row)
 	}
 
 	rightTable := join.Table.Name
+	rightQualifier := join.Table.Alias
+	if rightQualifier == "" {
+		rightQualifier = rightTable
+	}
 	var rightRows []storage.Row
 	var err error
 	switch {
 	case join.Table.Subquery != nil:
-		rightRows, _, err = e.materializeJoinSubquery(join.Table)
+		var columns, types []string
+		rightRows, _, columns, types, err = e.materializeJoinSubquery(join.Table)
+		e.recordJoinColumns(rightQualifier, columns, types)
 	case e.cteTableExists(rightTable):
 		cte, _ := e.cteTableFor(rightTable)
 		rightRows = cloneRows(cte.rows)
+		e.recordJoinColumns(rightQualifier, cte.columns, cte.types)
 	default:
 		rightRows, err = e.session.Select(rightTable, nil)
 	}
@@ -5382,6 +5455,10 @@ func (e *Executor) evalBinaryExpr(expr *parser.BinaryExpr, row storage.Row) (int
 		if err != nil {
 			return nil, err
 		}
+		if left == nil || right == nil {
+			return nil, nil
+		}
+		return e.compareExprValues(expr.Op, expr.Left, expr.Right, left, right)
 	}
 
 	if isBitwiseOp(expr.Op) {
@@ -6186,7 +6263,11 @@ func (e *Executor) evalInExpr(expr *parser.InExpr, row storage.Row) (interface{}
 			if canonErr != nil {
 				return nil, canonErr
 			}
-			if compare(left, v) == 0 {
+			equal, cmpErr := e.compareExprValues(lexer.TokenEq, expr.Left, nil, left, v)
+			if cmpErr != nil {
+				return nil, cmpErr
+			}
+			if equal {
 				if expr.Not {
 					return false, nil
 				}
@@ -6226,7 +6307,11 @@ func (e *Executor) evalInExpr(expr *parser.InExpr, row storage.Row) (interface{}
 		if err != nil {
 			return nil, err
 		}
-		if compare(left, v) == 0 {
+		equal, cmpErr := e.compareExprValues(lexer.TokenEq, expr.Left, nil, left, v)
+		if cmpErr != nil {
+			return nil, cmpErr
+		}
+		if equal {
 			if expr.Not {
 				return false, nil
 			}
@@ -6367,7 +6452,11 @@ func (e *Executor) evalIsDistinctExpr(expr *parser.IsDistinctExpr, row storage.R
 	case left == nil || right == nil:
 		distinct = true
 	default:
-		distinct = compare(left, right) != 0
+		equal, cmpErr := e.compareExprValues(lexer.TokenEq, expr.Left, expr.Right, left, right)
+		if cmpErr != nil {
+			return nil, cmpErr
+		}
+		distinct = !equal
 	}
 	if expr.Not {
 		return !distinct, nil
@@ -6893,6 +6982,10 @@ func (e *Executor) evalExprWithAggregates(expr parser.Expr, rows []storage.Row)
 			if err != nil {
 				return nil, err
 			}
+			if left == nil || right == nil {
+				return nil, nil
+			}
+			return e.compareExprValues(ex.Op, ex.Left, ex.Right, left, right)
 		}
 		// Use the same logic as evalBinaryExpr to preserve integer semantics.
 		combined := &parser.BinaryExpr{Op: ex.Op}
@@ -7116,7 +7209,8 @@ func (e *Executor) evalExprWithAggregates(expr parser.Expr, rows []storage.Row)
 		if err != nil {
 			return nil, fmt.Errorf("ANY/ALL requires an array operand: %w", err)
 		}
-		return evalQuantifiedComparison(ex.Op, ex.All, left, elements)
+		elementType, _ := e.arrayElementTypeOf(ex.Array)
+		return evalQuantifiedComparison(ex.Op, ex.All, left, elements, elementType)
 	case *parser.InExpr:
 		left, err := e.evalExprWithAggregates(ex.Left, rows)
 		if err != nil {
@@ -7162,7 +7256,11 @@ func (e *Executor) evalExprWithAggregates(expr parser.Expr, rows []storage.Row)
 				if canonErr != nil {
 					return nil, canonErr
 				}
-				if compare(left, v) == 0 {
+				equal, cmpErr := e.compareExprValues(lexer.TokenEq, ex.Left, nil, left, v)
+				if cmpErr != nil {
+					return nil, cmpErr
+				}
+				if equal {
 					if ex.Not {
 						return false, nil
 					}
@@ -7199,7 +7297,11 @@ func (e *Executor) evalExprWithAggregates(expr parser.Expr, rows []storage.Row)
 			if err != nil {
 				return nil, err
 			}
-			if compare(left, v) == 0 {
+			equal, cmpErr := e.compareExprValues(lexer.TokenEq, ex.Left, nil, left, v)
+			if cmpErr != nil {
+				return nil, cmpErr
+			}
+			if equal {
 				if ex.Not {
 					return false, nil
 				}

+ 92 - 0
pkg/executor/result_types.go

@@ -28,6 +28,11 @@ func (e *Executor) snapshotTypeResolver(refs []parser.TableRef) columnTypeResolv
 		if ref.Subquery != nil || ref.Function != nil || ref.Name == "" {
 			continue
 		}
+		// A materialized CTE shadows a real table with the same name; never
+		// read the table's schema for a name the query resolves as a CTE.
+		if e.cteTableExists(ref.Name) {
+			continue
+		}
 		schema, err := e.session.GetSchema(ref.Name)
 		if err != nil {
 			continue
@@ -55,6 +60,93 @@ func (e *Executor) snapshotTypeResolver(refs []parser.TableRef) columnTypeResolv
 	}
 }
 
+// joinTypeScope collects the column types of relations materialized while
+// executing a join (derived tables and CTEs on the right side). The statement's
+// WHERE and projection resolve through it after the join rows are built, so
+// array subscripts and comparisons still decode their elements.
+type joinTypeScope struct {
+	qualified   map[string]string
+	unqualified map[string]string
+}
+
+func newJoinTypeScope() *joinTypeScope {
+	return &joinTypeScope{qualified: make(map[string]string), unqualified: make(map[string]string)}
+}
+
+// record stores one column's type. Unqualified lookups keep the first relation
+// that declared the name, matching how merged join rows keep the left value.
+func (s *joinTypeScope) record(qualifier, name, typeName string) {
+	if s == nil {
+		return
+	}
+	if qualifier != "" {
+		s.qualified[strings.ToUpper(qualifier+"."+name)] = typeName
+	}
+	key := strings.ToUpper(name)
+	if _, exists := s.unqualified[key]; !exists {
+		s.unqualified[key] = typeName
+	}
+}
+
+// resolve implements columnTypeResolver over the recorded join columns.
+func (s *joinTypeScope) resolve(table, column string) (string, bool) {
+	if s == nil {
+		return "", false
+	}
+	if table != "" {
+		if typeName, ok := s.qualified[strings.ToUpper(table+"."+column)]; ok {
+			return typeName, true
+		}
+		return "", false
+	}
+	if typeName, ok := s.unqualified[strings.ToUpper(column)]; ok {
+		return typeName, true
+	}
+	return "", false
+}
+
+// recordJoinColumns adds a materialized right relation's columns to the current
+// statement's join scope.
+func (e *Executor) recordJoinColumns(qualifier string, columns, types []string) {
+	if len(e.joinTypeScopes) == 0 {
+		return
+	}
+	scope := e.joinTypeScopes[len(e.joinTypeScopes)-1]
+	for i, column := range columns {
+		scope.record(qualifier, column, typeAt(types, i))
+	}
+}
+
+// materializedTypeResolver builds a resolver over a materialized relation's
+// columns. columns and types are positionally aligned; qualifiers holds the
+// table name or alias each column can be referenced through (one entry per
+// column). A known column with no recorded type returns ("", true) so an outer
+// scope cannot supply a type for a name that is shadowed.
+func materializedTypeResolver(columns, types, qualifiers []string) columnTypeResolver {
+	if len(columns) == 0 {
+		return nil
+	}
+	return func(table, column string) (string, bool) {
+		for i, name := range columns {
+			if !strings.EqualFold(name, column) {
+				continue
+			}
+			qualifier := ""
+			if i < len(qualifiers) {
+				qualifier = qualifiers[i]
+			}
+			if table != "" && !strings.EqualFold(table, qualifier) {
+				continue
+			}
+			if i < len(types) && types[i] != "" {
+				return types[i], true
+			}
+			return "", true
+		}
+		return "", false
+	}
+}
+
 // pushTypeScope adds the column-type resolver of the SELECT or DML statement
 // being evaluated. Scopes nest: the innermost resolver wins, and an outer
 // query's resolver still answers for correlated references.