From 9d8528e23f0ee9a7530cac20f1fba910ea6ceb02 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 16:08:45 +0000 Subject: [PATCH 1/5] Register a bare query operand as a subquery scope build_scope only registered a subquery wrapper, EXISTS, IN and ANY/ALL operands, and walk_in_scope stops at a bare Select/Union/Intersect/Except. A query that is a plain operand of an expression, such as the argument of ARRAY(SELECT ...), therefore got no scope, and its tables and columns were invisible to everything built on the scope tree. sqlglot treats every unwrapped query in scope as a subquery. collect_subqueries_in_expr now also registers each bare query among the children of a node the walk yields, through the same branch/build_child path, so ScopeVisitor reports it. The existing pointer dedup keeps each operand to a single scope. FROM/JOIN items, CTE bodies and set operation branches are unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Fa6xBWiFS7JFH5kt1epZ7o --- CHANGELOG.md | 4 ++ crates/polyglot-sql/src/scope.rs | 118 +++++++++++++++++++++++++------ 2 files changed, 100 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7bfed23..00a7dd8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ The format is based on Keep a Changelog, and this project adheres to Semantic Ve ### Fixed +- `build_scope` now gives a subquery scope to a query that is a bare operand of + an expression, such as the argument of `ARRAY(SELECT ...)`, instead of + leaving its tables and columns out of the scope tree. This matches sqlglot, + which treats every unwrapped query in scope as a subquery. - `Scope::traverse` and `traverse_scope` now yield derived-table scopes in post-order. Previously they walked the never-populated `table_scopes`, so `traverse_scope("SELECT a FROM (SELECT b FROM t) x")` returned only the root diff --git a/crates/polyglot-sql/src/scope.rs b/crates/polyglot-sql/src/scope.rs index ed31db98..4fb4907b 100644 --- a/crates/polyglot-sql/src/scope.rs +++ b/crates/polyglot-sql/src/scope.rs @@ -1275,8 +1275,9 @@ fn collect_subqueries_in_expr( builder: &mut ScopeBuilder, ) { let mut seen = HashSet::new(); + let walker = WalkInScopeIter::new(expr, false); for node in walk_in_scope(expr, false) { - let query = match node { + let operand = match node { Expression::Subquery(subquery) if subquery.alias.is_none() => { Some(scope_query(&subquery.this)) } @@ -1287,22 +1288,35 @@ fn collect_subqueries_in_expr( } _ => None, }; + // The walk stops at a bare query, so find it among its parent's children. + let bare = walker + .get_children(node) + .into_iter() + .filter(|child| is_bare_query(child)); - let Some(query) = query else { - continue; - }; + for query in operand.into_iter().chain(bare) { + let key = query as *const Expression as usize; + if !seen.insert(key) { + continue; + } - let key = query as *const Expression as usize; - if !seen.insert(key) { - continue; + let sub_scope = builder.branch(parent_scope, query.clone(), ScopeType::Subquery); + let sub_scope = builder.build_child(sub_scope, query); + parent_scope.subquery_scopes.push(sub_scope); } - - let sub_scope = builder.branch(parent_scope, query.clone(), ScopeType::Subquery); - let sub_scope = builder.build_child(sub_scope, query); - parent_scope.subquery_scopes.push(sub_scope); } } +fn is_bare_query(expr: &Expression) -> bool { + matches!( + expr, + Expression::Select(_) + | Expression::Union(_) + | Expression::Intersect(_) + | Expression::Except(_) + ) +} + /// Walk within a scope, yielding expressions without crossing scope boundaries. /// /// This iterator visits all nodes in the syntax tree, stopping at nodes that @@ -1352,17 +1366,7 @@ impl<'a> WalkInScopeIter<'a> { } // Stop at standalone SELECT/UNION/etc that would be subqueries - if matches!( - expr, - Expression::Select(_) - | Expression::Union(_) - | Expression::Intersect(_) - | Expression::Except(_) - ) { - return true; - } - - false + is_bare_query(expr) } fn get_children(&self, expr: &'a Expression) -> Vec<&'a Expression> { @@ -2024,6 +2028,76 @@ mod tests { } } + #[test] + fn test_function_argument_query_is_one_subquery_scope() { + use crate::DialectType::{BigQuery, ClickHouse, Generic, PostgreSQL}; + + for (dialect, sql) in [ + (Generic, "SELECT ARRAY(SELECT x FROM u) FROM t"), + (BigQuery, "SELECT ARRAY(SELECT x FROM u) FROM t"), + (PostgreSQL, "SELECT ARRAY(SELECT x FROM u) FROM t"), + ( + Generic, + "SELECT 1 FROM t WHERE LENGTH(ARRAY(SELECT x FROM u)) > 0", + ), + (Generic, "SELECT 1 FROM t GROUP BY ARRAY(SELECT x FROM u)"), + ( + Generic, + "SELECT 1 FROM t HAVING ARRAY(SELECT x FROM u) = t.a", + ), + (Generic, "SELECT 1 FROM t ORDER BY ARRAY(SELECT x FROM u)"), + ( + Generic, + "SELECT 1 FROM t JOIN s ON ARRAY(SELECT x FROM u) = s.a", + ), + ( + Generic, + "SELECT 1 FROM t QUALIFY ARRAY(SELECT x FROM u) = t.a", + ), + (ClickHouse, "SELECT f(SELECT x FROM u) FROM t"), + ] { + let ast = crate::parse(sql, dialect).expect("Failed to parse SQL"); + let scope = build_scope(&ast[0]); + assert_eq!(scope.subquery_scopes.len(), 1, "{sql}"); + let subquery = &scope.subquery_scopes[0]; + assert!(subquery.is_subquery(), "{sql}"); + assert!(subquery.sources.contains_key("u"), "{sql}"); + assert!(!scope.sources.contains_key("u"), "{sql}"); + } + + let scope = + parse_and_build_scope("SELECT ARRAY(SELECT x FROM u UNION ALL SELECT y FROM v) FROM t"); + assert_eq!(scope.subquery_scopes.len(), 1); + assert_eq!(scope.subquery_scopes[0].union_scopes.len(), 2); + } + + #[test] + fn test_nested_and_mixed_subquery_operands_register_once() { + let scope = parse_and_build_scope( + "SELECT ARRAY(SELECT ARRAY(SELECT z FROM w) FROM u), \ + (SELECT 1 FROM v) \ + FROM t WHERE a IN (SELECT 1 FROM u) AND EXISTS (SELECT 1 FROM u)", + ); + assert_eq!(scope.subquery_scopes.len(), 4); + let array_arg = &scope.subquery_scopes[0]; + assert_eq!(array_arg.subquery_scopes.len(), 1); + assert!(array_arg.subquery_scopes[0].sources.contains_key("w")); + } + + #[test] + fn test_visitor_reports_function_argument_query() { + let (scope, recorder) = record( + "SELECT ARRAY(SELECT x FROM u) FROM t", + crate::DialectType::Generic, + ); + let [root, subquery] = &recorder.entered[..] else { + panic!("{:?}", recorder.entered); + }; + assert_eq!(scope.subquery_scopes[0].id(), Some(subquery.id)); + assert_eq!(subquery.parent, Some(root.id)); + assert!(matches!(subquery.scope_type, ScopeType::Subquery)); + } + #[test] fn test_union_output_columns() { let scope = parse_and_build_scope( From faaf3f84fdc8b45fa645425549265e5111bd1cea Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 16:02:49 +0000 Subject: [PATCH 2/5] Pass input-tree nodes to ScopeVisitor::enter_scope and make scope_query public enter_scope now receives the node from the expression given to build_scope_with instead of the scope's own copy, matching reference and skipped, so a visitor can link nodes of the input to the scopes built for them. scope_query is public and re-exported alongside build_scope_with. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Fa6xBWiFS7JFH5kt1epZ7o --- CHANGELOG.md | 8 ++ crates/polyglot-sql/src/lib.rs | 5 +- crates/polyglot-sql/src/scope.rs | 187 +++++++++++++++++++++++++------ 3 files changed, 165 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7bfed23..ffdd9d2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,14 @@ The format is based on Keep a Changelog, and this project adheres to Semantic Ve `ScopeVisitor`: scope entry and exit, each FROM/JOIN item in source order, and the FROM-item subtrees that get no scope. Every scope a build creates has a `ScopeId`, exposed as `Scope::id()`. +- `scope_query` is now public and re-exported: it unwraps `Cte`, `Subquery`, + `Paren`, `Alias` and similar wrappers to the query a scope is built from. + +- `ScopeVisitor::enter_scope` now receives a node of the expression passed to + `build_scope_with` instead of a copy, as `reference` and `skipped` already + did, so callers can key on node addresses. A CTE scope is the exception: it + still receives an owned `Expression::Cte`, because the input holds CTEs as + `Cte` structs rather than `Expression` nodes. The scope tree is unchanged. ### Fixed diff --git a/crates/polyglot-sql/src/lib.rs b/crates/polyglot-sql/src/lib.rs index fe295366..d856438a 100644 --- a/crates/polyglot-sql/src/lib.rs +++ b/crates/polyglot-sql/src/lib.rs @@ -132,8 +132,9 @@ pub use schema::{ }; #[cfg(feature = "semantic")] pub use scope::{ - build_scope, build_scope_with, find_all_in_scope, find_in_scope, traverse_scope, walk_in_scope, - ColumnRef, NoopScopeVisitor, Scope, ScopeId, ScopeType, ScopeVisitor, SourceInfo, + build_scope, build_scope_with, find_all_in_scope, find_in_scope, scope_query, traverse_scope, + walk_in_scope, ColumnRef, NoopScopeVisitor, Scope, ScopeId, ScopeType, ScopeVisitor, + SourceInfo, }; #[cfg(feature = "time")] pub use time::{format_time, is_valid_timezone, subsecond_precision, TIMEZONES}; diff --git a/crates/polyglot-sql/src/scope.rs b/crates/polyglot-sql/src/scope.rs index ed31db98..0b57edf2 100644 --- a/crates/polyglot-sql/src/scope.rs +++ b/crates/polyglot-sql/src/scope.rs @@ -56,7 +56,21 @@ impl Default for SourceKind { } /// Unwrap query containers without creating a new lexical context. -pub(crate) fn scope_query(expression: &Expression) -> &Expression { +/// +/// Peels `Cte`, `Subquery`, `Paren`, `Alias`, `Prepare` and +/// `CREATE TABLE ... AS SELECT` wrappers, which is how the scope builder finds +/// the query a scope is built from. Any other expression is returned as is. +/// +/// ``` +/// use polyglot_sql::{parse, scope_query, DialectType, Expression}; +/// +/// let ast = parse("SELECT 1 FROM (SELECT 2) AS d", DialectType::Generic).unwrap(); +/// let Expression::Select(select) = &ast[0] else { unreachable!() }; +/// let derived = &select.from.as_ref().unwrap().expressions[0]; +/// assert!(matches!(derived, Expression::Subquery(_))); +/// assert!(matches!(scope_query(derived), Expression::Select(_))); +/// ``` +pub fn scope_query(expression: &Expression) -> &Expression { match expression { Expression::Cte(cte) => scope_query(&cte.this), Expression::Subquery(subquery) => scope_query(&subquery.this), @@ -676,6 +690,18 @@ pub struct ScopeId(u32); /// change what is built. Ids reported here match [`Scope::id`] on the finished /// tree. /// +/// The expressions passed to `enter_scope`, `reference` and `skipped` are nodes +/// of the expression given to [`build_scope_with`], not copies, so a caller may +/// key on their address for as long as it keeps that expression alive. For a +/// derived table or subquery scope, `enter_scope` receives the query inside the +/// wrapper. +/// +/// A CTE scope is the one exception. The input holds a CTE as a `Cte` struct in +/// `With::ctes`, not as an `Expression` node, so `enter_scope` receives an +/// owned `Expression::Cte` (equal to [`Scope::expression`]) whose address is +/// not in the input. The CTE's body is an input node: it is `Cte::this` of the +/// matching entry in the input's `With`. +/// /// `enter_scope` and `exit_scope` nest like a stack: a child scope is entered /// and exited while its parent is open, and every `reference` and `skipped` /// event belongs to the innermost open scope. @@ -801,7 +827,7 @@ pub(crate) fn build_scope_with_ctes( visitor, next_id: 0, }; - let mut root = builder.register(Scope::new(expression.clone()), None); + let mut root = builder.register(Scope::new(expression.clone()), None, expression); root.cte_sources = ctes.clone(); build_scope_impl(expression, &mut root, &mut builder); builder.visitor.exit_scope(built_id(&root)); @@ -814,18 +840,23 @@ struct ScopeBuilder<'v> { } impl ScopeBuilder<'_> { - /// Assigns the next id to a newly created scope and reports it. - fn register(&mut self, mut scope: Scope, parent: Option) -> Scope { + /// Assigns the next id to a newly created scope and reports it, along with + /// the `input` node it was built from. + fn register(&mut self, mut scope: Scope, parent: Option, input: &Expression) -> Scope { let id = ScopeId(self.next_id); self.next_id += 1; scope.id = Some(id); self.visitor - .enter_scope(id, parent, scope.scope_type, &scope.expression); + .enter_scope(id, parent, scope.scope_type, input); scope } - fn branch(&mut self, parent: &Scope, expression: Expression, scope_type: ScopeType) -> Scope { - self.register(parent.branch(expression, scope_type), parent.id) + fn branch(&mut self, parent: &Scope, expression: &Expression, scope_type: ScopeType) -> Scope { + self.register( + parent.branch(expression.clone(), scope_type), + parent.id, + expression, + ) } /// Builds `body` into `child` and reports `child` as complete. @@ -914,12 +945,10 @@ fn build_scope_impl( process_ctes(with, current_scope, builder); } - let left_scope = - builder.branch(current_scope, union.left.clone(), ScopeType::SetOperation); + let left_scope = builder.branch(current_scope, &union.left, ScopeType::SetOperation); let left_scope = builder.build_child(left_scope, &union.left); - let right_scope = - builder.branch(current_scope, union.right.clone(), ScopeType::SetOperation); + let right_scope = builder.branch(current_scope, &union.right, ScopeType::SetOperation); let right_scope = builder.build_child(right_scope, &union.right); current_scope.union_scopes.push(left_scope); @@ -930,18 +959,12 @@ fn build_scope_impl( process_ctes(with, current_scope, builder); } - let left_scope = builder.branch( - current_scope, - intersect.left.clone(), - ScopeType::SetOperation, - ); + let left_scope = + builder.branch(current_scope, &intersect.left, ScopeType::SetOperation); let left_scope = builder.build_child(left_scope, &intersect.left); - let right_scope = builder.branch( - current_scope, - intersect.right.clone(), - ScopeType::SetOperation, - ); + let right_scope = + builder.branch(current_scope, &intersect.right, ScopeType::SetOperation); let right_scope = builder.build_child(right_scope, &intersect.right); current_scope.union_scopes.push(left_scope); @@ -952,12 +975,10 @@ fn build_scope_impl( process_ctes(with, current_scope, builder); } - let left_scope = - builder.branch(current_scope, except.left.clone(), ScopeType::SetOperation); + let left_scope = builder.branch(current_scope, &except.left, ScopeType::SetOperation); let left_scope = builder.build_child(left_scope, &except.left); - let right_scope = - builder.branch(current_scope, except.right.clone(), ScopeType::SetOperation); + let right_scope = builder.branch(current_scope, &except.right, ScopeType::SetOperation); let right_scope = builder.build_child(right_scope, &except.right); current_scope.union_scopes.push(left_scope); @@ -995,7 +1016,11 @@ fn process_ctes( for cte in &with.ctes { let cte_name = cte.alias.name.clone(); let cte_expr = Expression::Cte(Box::new(cte.clone())); - let mut cte_scope = builder.branch(current_scope, cte_expr.clone(), ScopeType::Cte); + let mut cte_scope = builder.register( + current_scope.branch(cte_expr.clone(), ScopeType::Cte), + current_scope.id, + &cte_expr, + ); cte_scope.outer_columns = identifier_names(&cte.columns); if with.recursive && cte_body_self_references(cte) { @@ -1064,8 +1089,7 @@ fn add_table_to_scope( .map(|a| a.name.clone()) .unwrap_or_default(); - let mut derived_scope = - builder.branch(scope, subquery.this.clone(), ScopeType::DerivedTable); + let mut derived_scope = builder.branch(scope, &subquery.this, ScopeType::DerivedTable); derived_scope.outer_columns = identifier_names(&subquery.column_aliases); if subquery.lateral { derived_scope.is_lateral = true; @@ -1104,8 +1128,7 @@ fn add_table_to_scope( Some(name) } Expression::Alias(alias) if is_query_like_relation(&alias.this) => { - let mut derived_scope = - builder.branch(scope, alias.this.clone(), ScopeType::DerivedTable); + let mut derived_scope = builder.branch(scope, &alias.this, ScopeType::DerivedTable); derived_scope.outer_columns = identifier_names(&alias.column_aliases); let derived_scope = builder.build_child(derived_scope, &alias.this); @@ -1219,8 +1242,7 @@ fn pivot_source_name(source: &Expression, explicit_alias: Option<&str>) -> Strin fn add_pivot_inner_scope(source: &Expression, scope: &mut Scope, builder: &mut ScopeBuilder) { match source { Expression::Subquery(subquery) => { - let derived_scope = - builder.branch(scope, subquery.this.clone(), ScopeType::DerivedTable); + let derived_scope = builder.branch(scope, &subquery.this, ScopeType::DerivedTable); let derived_scope = builder.build_child(derived_scope, &subquery.this); scope.derived_table_scopes.push(derived_scope); } @@ -1297,7 +1319,7 @@ fn collect_subqueries_in_expr( continue; } - let sub_scope = builder.branch(parent_scope, query.clone(), ScopeType::Subquery); + let sub_scope = builder.branch(parent_scope, query, ScopeType::Subquery); let sub_scope = builder.build_child(sub_scope, query); parent_scope.subquery_scopes.push(sub_scope); } @@ -2411,6 +2433,105 @@ mod tests { ); } + /// Records the node `enter_scope` reports for each scope. + #[derive(Default)] + struct EnteredNodes(Vec<(ScopeType, *const Expression)>); + + impl ScopeVisitor for EnteredNodes { + fn enter_scope( + &mut self, + _id: ScopeId, + _parent: Option, + scope_type: ScopeType, + expression: &Expression, + ) { + self.0.push((scope_type, expression)); + } + } + + fn entered_nodes(ast: &Expression, scope_type: ScopeType) -> Vec<*const Expression> { + let mut visitor = EnteredNodes::default(); + build_scope_with(ast, &mut visitor); + visitor + .0 + .into_iter() + .filter(|(entered, _)| *entered == scope_type) + .map(|(_, node)| node) + .collect() + } + + #[test] + fn test_visitor_enters_input_nodes() { + // The two scalar subqueries have identical bodies, so only their + // addresses tell them apart. + let ast = crate::parse( + "SELECT (SELECT 1) AS a, (SELECT 1) AS b", + Default::default(), + ) + .expect("Failed to parse SQL"); + let Expression::Select(select) = &ast[0] else { + panic!("expected a SELECT"); + }; + let expected: Vec<_> = select.expressions.iter().map(scope_query).collect(); + let entered = entered_nodes(&ast[0], ScopeType::Subquery); + assert_eq!(entered.len(), 2); + for (entered, expected) in entered.into_iter().zip(expected) { + assert!(std::ptr::eq(entered, expected)); + } + + let ast = crate::parse("SELECT * FROM (SELECT 1) AS d", Default::default()) + .expect("Failed to parse SQL"); + let Expression::Select(select) = &ast[0] else { + panic!("expected a SELECT"); + }; + let derived = &select.from.as_ref().unwrap().expressions[0]; + let entered = entered_nodes(&ast[0], ScopeType::DerivedTable); + assert_eq!(entered.len(), 1); + assert!(std::ptr::eq(entered[0], scope_query(derived))); + + let ast = crate::parse("WITH c AS (SELECT 1) SELECT * FROM c", Default::default()) + .expect("Failed to parse SQL"); + let Expression::Select(select) = &ast[0] else { + panic!("expected a SELECT"); + }; + let cte = &select.with.as_ref().unwrap().ctes[0]; + let entered = entered_nodes(&ast[0], ScopeType::Cte); + assert_eq!(entered.len(), 1); + assert!(!std::ptr::eq(entered[0], &cte.this)); + } + + #[test] + fn test_visitor_enters_cte_as_cte_expression() { + #[derive(Default)] + struct CteNames(Vec); + + impl ScopeVisitor for CteNames { + fn enter_scope( + &mut self, + _id: ScopeId, + _parent: Option, + scope_type: ScopeType, + expression: &Expression, + ) { + if scope_type == ScopeType::Cte { + let Expression::Cte(cte) = expression else { + panic!("expected an Expression::Cte, got {expression:?}"); + }; + self.0.push(cte.alias.name.clone()); + } + } + } + + let ast = crate::parse( + "WITH a AS (SELECT 1), b AS (SELECT 2) SELECT * FROM a, b", + Default::default(), + ) + .expect("Failed to parse SQL"); + let mut visitor = CteNames::default(); + build_scope_with(&ast[0], &mut visitor); + assert_eq!(visitor.0, ["a", "b"]); + } + #[test] fn test_create_table_as_select_traverse() { let ast = Parser::parse_sql("CREATE TABLE t AS SELECT a FROM src").unwrap(); From 36720e2ca7f05ff961c043cef46f24aae21c2edd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 16:31:03 +0000 Subject: [PATCH 3/5] Don't format the expression in a scope test's panic message Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Fa6xBWiFS7JFH5kt1epZ7o --- crates/polyglot-sql/src/scope.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/polyglot-sql/src/scope.rs b/crates/polyglot-sql/src/scope.rs index 0b57edf2..436ba856 100644 --- a/crates/polyglot-sql/src/scope.rs +++ b/crates/polyglot-sql/src/scope.rs @@ -2515,7 +2515,7 @@ mod tests { ) { if scope_type == ScopeType::Cte { let Expression::Cte(cte) = expression else { - panic!("expected an Expression::Cte, got {expression:?}"); + panic!("expected an Expression::Cte"); }; self.0.push(cte.alias.name.clone()); } From 50a4cc7ea9f3966456bfb18d2320b06205f85873 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 16:32:30 +0000 Subject: [PATCH 4/5] Register an ANY/ALL operand only when it is a query The quantified-expression arm registered its operand as a scope whatever its shape, so `x = ANY(ARRAY(SELECT y FROM u))` produced an empty scope for the ARRAY(...) function in addition to the scope of the inner query that the bare operand arm now registers. Register the operand only when it is itself a query; an array or value operand is walked like any other expression, so the statement yields one subquery scope with the inner query's sources. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Fa6xBWiFS7JFH5kt1epZ7o --- CHANGELOG.md | 4 +++- crates/polyglot-sql/src/scope.rs | 13 ++++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00a7dd8c..ba10ae82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,9 @@ The format is based on Keep a Changelog, and this project adheres to Semantic Ve - `build_scope` now gives a subquery scope to a query that is a bare operand of an expression, such as the argument of `ARRAY(SELECT ...)`, instead of leaving its tables and columns out of the scope tree. This matches sqlglot, - which treats every unwrapped query in scope as a subquery. + which treats every unwrapped query in scope as a subquery. An `ANY`/`ALL` + array operand such as `ANY(ARRAY(SELECT ...))` no longer gets an empty scope + of its own, so that expression now yields one subquery scope instead of two. - `Scope::traverse` and `traverse_scope` now yield derived-table scopes in post-order. Previously they walked the never-populated `table_scopes`, so `traverse_scope("SELECT a FROM (SELECT b FROM t) x")` returned only the root diff --git a/crates/polyglot-sql/src/scope.rs b/crates/polyglot-sql/src/scope.rs index 4fb4907b..0bb0d64d 100644 --- a/crates/polyglot-sql/src/scope.rs +++ b/crates/polyglot-sql/src/scope.rs @@ -1284,7 +1284,7 @@ fn collect_subqueries_in_expr( Expression::Exists(exists) => Some(&exists.this), Expression::In(in_expr) => in_expr.query.as_ref(), Expression::Any(quantified) | Expression::All(quantified) => { - Some(scope_query(&quantified.subquery)) + Some(scope_query(&quantified.subquery)).filter(|query| is_bare_query(query)) } _ => None, }; @@ -2069,6 +2069,17 @@ mod tests { parse_and_build_scope("SELECT ARRAY(SELECT x FROM u UNION ALL SELECT y FROM v) FROM t"); assert_eq!(scope.subquery_scopes.len(), 1); assert_eq!(scope.subquery_scopes[0].union_scopes.len(), 2); + + // An array operand of ANY/ALL is not a query, so only its inner query gets a scope. + for sql in [ + "SELECT 1 FROM t WHERE x = ANY(ARRAY(SELECT y FROM u))", + "SELECT 1 FROM t WHERE x = ALL(ARRAY(SELECT y FROM u))", + ] { + let scope = parse_and_build_scope(sql); + assert_eq!(scope.subquery_scopes.len(), 1, "{sql}"); + assert!(scope.subquery_scopes[0].is_subquery(), "{sql}"); + assert!(scope.subquery_scopes[0].sources.contains_key("u"), "{sql}"); + } } #[test] From 141f912bf5c654226b55a1956213338e0e9a8fdd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 16:33:04 +0000 Subject: [PATCH 5/5] Don't format recorded scopes in a scope test's panic message Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Fa6xBWiFS7JFH5kt1epZ7o --- crates/polyglot-sql/src/scope.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/polyglot-sql/src/scope.rs b/crates/polyglot-sql/src/scope.rs index 0bb0d64d..141a010e 100644 --- a/crates/polyglot-sql/src/scope.rs +++ b/crates/polyglot-sql/src/scope.rs @@ -2102,7 +2102,7 @@ mod tests { crate::DialectType::Generic, ); let [root, subquery] = &recorder.entered[..] else { - panic!("{:?}", recorder.entered); + panic!("expected a root scope and one subquery scope"); }; assert_eq!(scope.subquery_scopes[0].id(), Some(subquery.id)); assert_eq!(subquery.parent, Some(root.id));