## Code Analysis ### Set-operation planning defines a common output type `crates/sql/src/plan.rs:869-899` ```rust /// plan_set_operation plans UNION, INTERSECT, or EXCEPT, whose columns take the names of the left query and the /// common types of both. fn plan_set_operation(&mut self, select: &SelectStmt, op: SetOperation) -> Result<(Query, Scope)> { let mut types = Vec::new(); for (l, r) in left.types.iter().zip(&right.types) { types.push(common_type(&[(*l, -1), (*r, -1)], name)?); } for query in [&mut left, &mut right] { if query.types != types { let exprs = (0..types.len()) .map(|i| coerce((Expr::Column(i), query.types[i]), types[i], false, -1).map(|b| b.0)) .collect::>>()?; query.plan = Plan::Project { input: Box::new(std::mem::replace(&mut query.plan, Plan::OneRow)), exprs }; } } let columns: Vec = left.columns.iter().zip(&types).map(|(c, &t)| column(c.name.clone(), t)).collect(); ``` The normal planning path computes a common type for each pair of set-operation branches, coerces each branch to it, and records it in the output columns. ### Optimizer reconstruction replaces the resolved type metadata `crates/sql/src/optimizer/query.rs:193-211,234-240` ```rust fn set_operation_query(glob: &mut PlannerGlobal, ctx: &mut Ctx<'_>, plan: Plan, columns: &mut Vec) -> Query { let width = plan.width(); let mut rtable = Vec::new(); // ... build the set-operation tree and its range-table entries ... let col_types = rtable.first().map(|rte: &super::nodes::RangeTblEntry| rte.coltypes.clone()).unwrap_or_default(); set_col_types(&mut stmt, &col_types); columns.extend((0..width).map(|i| glob.var(0, i, super::nodes::Relids::new()))); Query { rtable, set_operations: Some(Box::new(stmt)), recursion, ..Query::default() } } fn set_col_types(stmt: &mut SetOperationStmt, col_types: &[Option]) { stmt.col_types = col_types.to_vec(); for arg in [&mut stmt.larg, &mut stmt.rarg] { if let SetOpTree::Op(op) = arg { set_col_types(op, col_types); } } } ``` The reconstructed query takes `col_types` from the first range-table entry and propagates that vector through the set-operation tree, rather than carrying the already-resolved common output types from the original plan. The outer columns are relation-zero variables; `crates/sql/src/optimizer/nodefuncs.rs:33-40` resolves those variables from `parse.set_operations.col_types`, so this metadata controls the type seen by an outer predicate. ### Existing regression coverage expects typed output `crates/tests/tests/scripts/union.rs:33-50` ```rust query: "SELECT * FROM t1 EXCEPT SELECT * FROM t2;", expected: Expected::Rows { columns: &[Column("i", INT4)], rows: &[ &[T("1")], ], tag: "SELECT 1", }, // ... query: "SELECT 123 EXCEPT SELECT 456;", expected: Expected::Rows { columns: &[Column("?column?", INT4)], ``` The existing tests establish that an integer EXCEPT result retains an integer column type, not generic text. ### Observed execution ### predicate request ```sql SELECT x FROM (SELECT 1::smallint AS x UNION SELECT 2::bigint UNION SELECT NULL::bigint) u WHERE x >= 2 OR x IS NULL; ``` ### predicate response ```text ERROR: operator does not exist: text >= integer ``` ### UNION type inspection ```sql SELECT x, pg_typeof(x) FROM (SELECT 1::smallint AS x UNION SELECT 2::bigint UNION SELECT NULL::bigint) AS u; ``` ```text x | pg_typeof ---+----------- 1 | text 2 | text | text (3 rows) ``` ### EXCEPT type inspection ```sql SELECT x, pg_typeof(x) FROM ((SELECT 1::integer AS x UNION ALL SELECT NULL::integer UNION ALL SELECT 3::integer) EXCEPT (SELECT 1::bigint AS x UNION ALL SELECT NULL::bigint)) AS e; ``` ```text x | pg_typeof ---+----------- 3 | text (1 row) ``` ### typed controls ```text UNION_CAST_FILTER: 2 (text), NULL (text) EXCEPT_CAST_FILTER: 3 (text) UNION_POST_MATERIALIZATION: 2 (bigint), NULL (bigint) EXCEPT_POST_MATERIALIZATION: 3 (bigint) ``` ### Result The captured queries show that mixed numeric UNION and EXCEPT outputs are exposed as `text`, causing the ordinary `x >= 2` predicate to fail with a type error. The source path explains how optimizer reconstruction can replace the common numeric output metadata; explicit casts or typed post-materialization references work around the failure, but the unmodified numeric-filter queries do not return their expected rows.