diff --git a/Cargo.lock b/Cargo.lock index adcf5c3c..a3f83f52 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -605,7 +605,7 @@ dependencies = [ [[package]] name = "polyglot-sql" -version = "0.13.7" +version = "0.13.8" dependencies = [ "criterion", "once_cell", @@ -624,7 +624,7 @@ dependencies = [ [[package]] name = "polyglot-sql-ast-derive" -version = "0.13.7" +version = "0.13.8" dependencies = [ "proc-macro2", "quote", @@ -633,7 +633,7 @@ dependencies = [ [[package]] name = "polyglot-sql-ffi" -version = "0.13.7" +version = "0.13.8" dependencies = [ "cbindgen", "polyglot-sql", @@ -643,11 +643,11 @@ dependencies = [ [[package]] name = "polyglot-sql-function-catalogs" -version = "0.13.7" +version = "0.13.8" [[package]] name = "polyglot-sql-python" -version = "0.13.7" +version = "0.13.8" dependencies = [ "polyglot-sql", "pyo3", @@ -658,7 +658,7 @@ dependencies = [ [[package]] name = "polyglot-sql-wasm" -version = "0.13.7" +version = "0.13.8" dependencies = [ "console_error_panic_hook", "js-sys", diff --git a/Cargo.toml b/Cargo.toml index db026e85..4ef9e54b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ exclude = [ ] [workspace.package] -version = "0.13.7" +version = "0.13.8" edition = "2021" license = "MIT" authors = ["polyglot contributors"] diff --git a/README.md b/README.md index f71d9881..8e5a6442 100644 --- a/README.md +++ b/README.md @@ -306,7 +306,7 @@ If you want to disable `stacker` for a native Rust build, turn off default featu ```toml [dependencies] -polyglot-sql = { version = "0.13.7", default-features = false, features = ["all-dialects", "transpile"] } +polyglot-sql = { version = "0.13.8", default-features = false, features = ["all-dialects", "transpile"] } ``` That can reduce overhead slightly on trusted inputs, but you lose the default stack-growth protection for deeply nested SQL. diff --git a/crates/polyglot-sql-function-catalogs/README.md b/crates/polyglot-sql-function-catalogs/README.md index 6edc4e0f..523753f9 100644 --- a/crates/polyglot-sql-function-catalogs/README.md +++ b/crates/polyglot-sql-function-catalogs/README.md @@ -25,7 +25,7 @@ With `polyglot-sql` compile-time wiring (recommended): ```toml [dependencies] -polyglot-sql = { version = "0.13.7", features = ["function-catalog-clickhouse"] } +polyglot-sql = { version = "0.13.8", features = ["function-catalog-clickhouse"] } ``` Then run schema validation with type checks: diff --git a/crates/polyglot-sql/Cargo.toml b/crates/polyglot-sql/Cargo.toml index 1fc3ecba..fc165841 100644 --- a/crates/polyglot-sql/Cargo.toml +++ b/crates/polyglot-sql/Cargo.toml @@ -100,14 +100,14 @@ function-catalog-all-dialects = [ [dependencies] regex = { version = "1", optional = true } -polyglot-sql-ast-derive = { path = "../polyglot-sql-ast-derive", version = "0.13.7" } +polyglot-sql-ast-derive = { path = "../polyglot-sql-ast-derive", version = "0.13.8" } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } unicode-segmentation = { workspace = true } stacker = { version = "0.1", optional = true } ts-rs = { version = "12.0", features = ["serde-compat"], optional = true } -polyglot-sql-function-catalogs = { path = "../polyglot-sql-function-catalogs", version = "0.13.7", optional = true, default-features = false } +polyglot-sql-function-catalogs = { path = "../polyglot-sql-function-catalogs", version = "0.13.8", optional = true, default-features = false } [dev-dependencies] pretty_assertions = "1.4" diff --git a/crates/polyglot-sql/README.md b/crates/polyglot-sql/README.md index 8b16c939..dfe0bc89 100644 --- a/crates/polyglot-sql/README.md +++ b/crates/polyglot-sql/README.md @@ -25,12 +25,12 @@ By default, `polyglot-sql` enables the full public API. Parser-only consumers ca disable default features and opt into only the dialect parsers they need: ```toml -polyglot-sql = { version = "0.13.7", default-features = false } +polyglot-sql = { version = "0.13.8", default-features = false } ``` ```toml polyglot-sql = { - version = "0.13.7", + version = "0.13.8", default-features = false, features = ["dialect-clickhouse"], } @@ -44,14 +44,14 @@ Examples: ```toml # Parse and generate SQL for one dialect. polyglot-sql = { - version = "0.13.7", + version = "0.13.8", default-features = false, features = ["generate", "dialect-clickhouse"], } # Cross-dialect transpilation. polyglot-sql = { - version = "0.13.7", + version = "0.13.8", default-features = false, features = ["transpile", "dialect-clickhouse", "dialect-postgresql"], } diff --git a/crates/polyglot-sql/src/lineage.rs b/crates/polyglot-sql/src/lineage.rs index d75cbfeb..8cca2a70 100644 --- a/crates/polyglot-sql/src/lineage.rs +++ b/crates/polyglot-sql/src/lineage.rs @@ -1048,6 +1048,9 @@ struct LineageScopeContext<'a> { scopes: Vec>, scope_ids: HashMap<*const Scope, ScopeId>, resolved_states: RefCell>, + /// Child-scope lookups by source name; each lookup is a pure function of the + /// immutable scope tree, and derived-table matching compares whole queries. + child_scopes: RefCell>>, /// Usage analysis must not claim undeclared qualifiers as physical tables. conservative: bool, } @@ -1228,6 +1231,7 @@ impl<'a> LineageScopeContext<'a> { scopes: Vec::new(), scope_ids: HashMap::new(), resolved_states: RefCell::new(HashSet::new()), + child_scopes: RefCell::new(HashMap::new()), conservative: false, schema: None, }; @@ -3544,6 +3548,20 @@ fn find_child_scope( context: &LineageScopeContext, scope_id: ScopeId, source_name: &str, +) -> Option { + let key = (scope_id, source_name.to_owned()); + if let Some(found) = context.child_scopes.borrow().get(&key) { + return *found; + } + let found = find_child_scope_uncached(context, scope_id, source_name); + context.child_scopes.borrow_mut().insert(key, found); + found +} + +fn find_child_scope_uncached( + context: &LineageScopeContext, + scope_id: ScopeId, + source_name: &str, ) -> Option { let indexed = context.indexed(scope_id); let scope = &indexed.scope; diff --git a/crates/polyglot-sql/src/query_analysis.rs b/crates/polyglot-sql/src/query_analysis.rs index 7b1629cd..11f5a1c4 100644 --- a/crates/polyglot-sql/src/query_analysis.rs +++ b/crates/polyglot-sql/src/query_analysis.rs @@ -850,6 +850,8 @@ struct NullabilityContext<'a> { scopes: Vec>, scope_ids: HashMap<*const Scope, usize>, outputs: RefCell>, + /// Derived-table scope per (frame, source name); matching compares whole queries. + derived_scopes: RefCell>>, resolving: RefCell>, uncertain_columns: &'a HashMap<(usize, usize), ReferenceConfidence>, } @@ -888,6 +890,7 @@ impl<'a> NullabilityContext<'a> { empty_schema: MappingSchema::with_dialect(dialect), dialect, scopes: Vec::new(), + derived_scopes: RefCell::new(HashMap::new()), scope_ids: HashMap::new(), outputs: RefCell::new(HashMap::new()), resolving: RefCell::new(HashSet::new()), @@ -2807,10 +2810,16 @@ impl NullabilityContext<'_> { } } if source.kind == SourceKind::DerivedTable && source.is_scope { - return frame.derived.iter().copied().find(|id| { + let key = (frame.scope as *const Scope, name.to_owned()); + if let Some(found) = self.derived_scopes.borrow().get(&key) { + return *found; + } + let found = frame.derived.iter().copied().find(|id| { crate::scope::scope_query(&self.scopes[*id].scope.expression) == crate::scope::scope_query(&source.expression) }); + self.derived_scopes.borrow_mut().insert(key, found); + return found; } None } diff --git a/crates/polyglot-sql/src/scope.rs b/crates/polyglot-sql/src/scope.rs index b07b345e..ae20aea6 100644 --- a/crates/polyglot-sql/src/scope.rs +++ b/crates/polyglot-sql/src/scope.rs @@ -75,7 +75,46 @@ pub(crate) fn scope_query(expression: &Expression) -> &Expression { /// declarations remain available, but do not themselves cause ambiguity. pub(crate) fn selected_reference_scope(scope: &Scope) -> Scope { let query = scope_query(&scope.expression); - let aliases: HashSet<_> = walk_in_scope(query, false) + let selected_expression = match query { + Expression::Select(select) => Expression::Select(Box::new(select_without_with(select))), + _ => query.clone(), + }; + selected_scope_view(scope, selected_expression) +} + +/// `selected_reference_scope` for a scope tree its caller owns. The query is +/// moved into the view instead of copied, leaving `scope.expression` as NULL; +/// the removed WITH clause is returned. Child scopes are unaffected. +pub(crate) fn take_selected_reference_scope( + scope: &mut Scope, +) -> (Scope, Option) { + fn into_scope_query(expression: Expression) -> Expression { + match expression { + Expression::Cte(cte) => into_scope_query(cte.this), + Expression::Subquery(subquery) => into_scope_query(subquery.this), + Expression::Paren(paren) => into_scope_query(paren.this), + Expression::Alias(alias) => into_scope_query(alias.this), + Expression::Annotated(annotated) => into_scope_query(annotated.this), + Expression::Prepare(prepare) => into_scope_query(prepare.statement), + Expression::CreateTable(create) if create.as_select.is_some() => { + into_scope_query(create.as_select.unwrap()) + } + expression => expression, + } + } + let mut query = into_scope_query(std::mem::replace( + &mut scope.expression, + Expression::Null(crate::expressions::Null), + )); + let with = match &mut query { + Expression::Select(select) => select.with.take(), + _ => None, + }; + (selected_scope_view(scope, query), with) +} + +fn selected_scope_view(scope: &Scope, query: Expression) -> Scope { + let aliases: HashSet<_> = walk_in_scope(&query, false) .filter_map(|node| match node { Expression::Table(table) => { Some(table.alias.as_ref().unwrap_or(&table.name).name.clone()) @@ -83,11 +122,7 @@ pub(crate) fn selected_reference_scope(scope: &Scope) -> Scope { _ => None, }) .collect(); - let selected_expression = match query { - Expression::Select(select) => Expression::Select(Box::new(select_without_with(select))), - _ => query.clone(), - }; - let mut selected = Scope::new(selected_expression); + let mut selected = Scope::new(query); selected.cte_sources = scope.cte_sources.clone(); selected.sources = scope .sources diff --git a/crates/polyglot-sql/src/validation.rs b/crates/polyglot-sql/src/validation.rs index 3b77797e..7578b05e 100644 --- a/crates/polyglot-sql/src/validation.rs +++ b/crates/polyglot-sql/src/validation.rs @@ -3596,6 +3596,15 @@ struct ReferenceValidationOptions { check_types: bool, } +/// Per-SELECT semantic checks run on the reference-validation scope tree, so a +/// query's scopes are built once. `selects` counts the SELECT scopes visited. +struct SemanticScopeChecks<'a> { + schema: &'a ValidationSchema, + mapping: &'a MappingSchema, + errors: Vec, + selects: usize, +} + #[derive(Clone, Copy, PartialEq, Eq)] enum OutputNameResolution { InputOnly, @@ -3749,12 +3758,50 @@ fn validate_scope_columns( } } } - let mut normalized = select.clone(); - let mut normalizer = Resolver::new(&visible, resolver_schema, true); - let _ = normalize_dotted_columns_in_scope(&mut normalized, &visible, &mut normalizer); + // Only a qualifier that names no visible source can be a struct access. + // Without one, normalization is the identity and the scope's own SELECT is + // read in place. + let normalized = walk_in_scope(&scope.expression, false) + .any(|node| { + matches!(node, Expression::Column(column) if column.table.as_ref().is_some_and(|root| { + !visible + .sources + .keys() + .any(|source| source.eq_ignore_ascii_case(&root.name)) + })) + }) + .then(|| { + let mut normalized = select.clone(); + let mut normalizer = Resolver::new(&visible, resolver_schema, true); + let _ = normalize_dotted_columns_in_scope(&mut normalized, &visible, &mut normalizer); + // Separate query-level ordering from the ordinary scope walk. This + // avoids accidentally exempting a same-named reference in another + // clause, window, or nested query, and leaves the caller's AST untouched. + let order_by = normalized.order_by.take(); + (Expression::Select(normalized), order_by) + }); + let (expression, order_by, order_nodes) = match &normalized { + Some((expression, order_by)) => (expression, order_by.as_ref(), HashSet::new()), + None => { + let order_by = select.order_by.as_ref(); + let nodes: HashSet<*const Column> = order_by + .into_iter() + .flat_map(|order| &order.expressions) + .flat_map(|ordered| walk_in_scope(&ordered.this, false)) + .filter_map(|node| match node { + Expression::Column(column) => Some(column.as_ref() as *const Column), + _ => None, + }) + .collect(); + (&scope.expression, order_by, nodes) + } + }; + let Expression::Select(normalized_select) = expression else { + unreachable!("validated scope SELECT") + }; let strategy = get_normalization_strategy(Some(dialect)); let mut output_names = HashMap::::new(); - for projection in &normalized.expressions { + for projection in &normalized_select.expressions { let identifier = match projection { // Until wildcards are expanded, output-name uniqueness is not // known. Preserve source validation instead of letting an explicit @@ -3774,11 +3821,6 @@ fn validate_scope_columns( let name = normalize_identifier(identifier.clone(), strategy).name; *output_names.entry(name).or_default() += 1; } - // Separate query-level ordering from the ordinary scope walk. This avoids - // accidentally exempting a same-named reference in another clause, window, - // or nested query, and leaves the caller's AST untouched. - let order_by = normalized.order_by.take(); - let expression = Expression::Select(normalized); let sources_by_scope: Vec<_> = std::iter::once(scope) .chain(ancestors.iter().copied()) .map(|scope| { @@ -3834,7 +3876,7 @@ fn validate_scope_columns( // indexed above. Do not falsely bind them against generated pivot columns. // Nested input queries still receive their own lexical validation pass. let mut pivot_inputs = HashSet::new(); - for node in walk_in_scope(&expression, false) { + for node in walk_in_scope(expression, false) { if let Expression::Unpivot(unpivot) = node { let resolver = Resolver::new(scope, resolver_schema, true); let columns = resolver.get_source_output_columns(&unpivot.this); @@ -3887,21 +3929,23 @@ fn validate_scope_columns( } } } - let input_columns = walk_in_scope(&expression, false).filter_map(|node| match node { - Expression::Column(column) if !pivot_inputs.contains(&(column.as_ref() as *const _)) => { + let input_columns = walk_in_scope(expression, false).filter_map(|node| match node { + Expression::Column(column) + if !pivot_inputs.contains(&(column.as_ref() as *const _)) + && !order_nodes.contains(&(column.as_ref() as *const _)) => + { Some((column.as_ref(), OutputNameResolution::InputOnly)) } _ => None, }); let order_columns = order_by - .as_ref() .into_iter() .flat_map(|order| order_by_validation_columns(order, dialect)); // A lateral relation's arguments see preceding sources, never its own // generated columns or following relations. Ordinary projections see all. let mut lateral_inputs = HashMap::new(); let mut preceding = HashSet::new(); - if let Expression::Select(selected) = &expression { + if let Expression::Select(selected) = expression { for relation in selected .from .iter() @@ -4091,29 +4135,91 @@ fn validate_scope_columns( errors } +/// A scope tree node under reference validation. An owned tree is consumed: +/// each query moves into its selected view instead of being copied. +enum ValidationScope<'a> { + Shared(&'a crate::scope::Scope), + Owned(&'a mut crate::scope::Scope), +} + +impl ValidationScope<'_> { + fn get(&self) -> &crate::scope::Scope { + match self { + Self::Shared(scope) => scope, + Self::Owned(scope) => scope, + } + } + + fn selected(&mut self) -> (crate::scope::Scope, Option) { + match self { + Self::Shared(scope) => (selected_validation_scope(scope), None), + Self::Owned(scope) => crate::scope::take_selected_reference_scope(scope), + } + } + + /// Correlated children (subqueries and UDTFs), or otherwise CTE, derived + /// table, and set-operation children. + fn children(&mut self, correlated: bool) -> Vec> { + match self { + Self::Shared(scope) => { + let children: Vec<_> = if correlated { + scope + .subquery_scopes + .iter() + .chain(&scope.udtf_scopes) + .collect() + } else { + scope + .cte_scopes + .iter() + .chain(&scope.derived_table_scopes) + .chain(&scope.union_scopes) + .collect() + }; + children.into_iter().map(ValidationScope::Shared).collect() + } + Self::Owned(scope) => { + let children: Vec<_> = if correlated { + scope + .subquery_scopes + .iter_mut() + .chain(scope.udtf_scopes.iter_mut()) + .collect() + } else { + scope + .cte_scopes + .iter_mut() + .chain(scope.derived_table_scopes.iter_mut()) + .chain(scope.union_scopes.iter_mut()) + .collect() + }; + children.into_iter().map(ValidationScope::Owned).collect() + } + } + } +} + +#[allow(clippy::too_many_arguments)] fn validate_scope_tree( - scope: &crate::scope::Scope, + mut scope: ValidationScope<'_>, ancestors: &[&crate::scope::Scope], schema_map: &HashMap, resolver_schema: &MappingSchema, options: ReferenceValidationOptions, errors: &mut Vec, bindings: &mut ProjectionAliasBindings, + mut semantics: Option<&mut SemanticScopeChecks<'_>>, ) { + let tree = scope.get(); if matches!( - scope_query(&scope.expression), + scope_query(&tree.expression), Expression::Union(_) | Expression::Intersect(_) | Expression::Except(_) - ) && scope.subquery_scopes.is_empty() - && scope.udtf_scopes.is_empty() - && !walk_in_scope(scope_query(&scope.expression), false) + ) && tree.subquery_scopes.is_empty() + && tree.udtf_scopes.is_empty() + && !walk_in_scope(scope_query(&tree.expression), false) .any(|node| matches!(node, Expression::Table(_))) { - for child in scope - .cte_scopes - .iter() - .chain(&scope.derived_table_scopes) - .chain(&scope.union_scopes) - { + for child in scope.children(false) { validate_scope_tree( child, ancestors, @@ -4122,22 +4228,40 @@ fn validate_scope_tree( options, errors, bindings, + semantics.as_deref_mut(), ); } return; } - let mut selected = selected_validation_scope(scope); + let (mut selected, taken_with) = scope.selected(); + if let Some(checks) = semantics.as_deref_mut() { + let with = taken_with + .as_ref() + .or_else(|| match scope_query(&scope.get().expression) { + Expression::Select(select) => select.with.as_ref(), + _ => None, + }); + let node = &selected.expression; + if let Expression::Select(select) = node { + checks.selects += 1; + semantics::check_select_semantics( + node, + select, + with, + &selected, + options.dialect, + Some(checks.schema), + checks.mapping, + &mut checks.errors, + ); + } + } // Bind children before consumers, but retain the existing parent-first // diagnostic order. Only physical sources are inherited, never output aliases. let mut child_errors = Vec::new(); // CTEs and ordinary derived tables cannot see their containing SELECT's // sources, but may retain ancestors of an enclosing correlated subquery. - for child in scope - .cte_scopes - .iter() - .chain(&scope.derived_table_scopes) - .chain(&scope.union_scopes) - { + for child in scope.children(false) { validate_scope_tree( child, ancestors, @@ -4146,18 +4270,19 @@ fn validate_scope_tree( options, &mut child_errors, bindings, + semantics.as_deref_mut(), ); } let outer: Vec<_> = std::iter::once(&selected) .chain(ancestors.iter().copied()) .collect(); - for child in scope.subquery_scopes.iter().chain(&scope.udtf_scopes) { + for child in scope.children(true) { // A LATERAL subquery inherits only preceding relations. Pass that // lexical environment into the entire child tree, so nested correlated // queries share the restriction while their own FROM sources stay local. let mut lateral_outer = None; - if child.scope_type == crate::scope::ScopeType::Udtf { - if let Expression::Select(select) = scope_query(&scope.expression) { + if child.get().scope_type == crate::scope::ScopeType::Udtf { + if let Expression::Select(select) = &selected.expression { let mut preceding = HashSet::new(); for relation in select .from @@ -4165,7 +4290,7 @@ fn validate_scope_tree( .flat_map(|from| &from.expressions) .chain(select.joins.iter().map(|join| &join.this)) { - if matches!(relation, Expression::Subquery(query) if query.lateral && query.this == child.expression) + if matches!(relation, Expression::Subquery(query) if query.lateral && query.this == child.get().expression) { let mut visible = selected.clone(); visible.sources.retain(|name, _| preceding.contains(name)); @@ -4215,6 +4340,7 @@ fn validate_scope_tree( options, &mut child_errors, bindings, + semantics.as_deref_mut(), ); } bind_scope_projection_aliases( @@ -4274,6 +4400,7 @@ fn validate_statement_with_schema( resolver_schema: &MappingSchema, options: ReferenceValidationOptions, bindings: &mut ProjectionAliasBindings, + mut semantics: Option<&mut SemanticScopeChecks<'_>>, ) -> Vec { let mut errors = Vec::new(); // Visit each query tree once, including queries embedded in DML/DDL. @@ -4281,15 +4408,16 @@ fn validate_statement_with_schema( while let Some(expression) = pending.pop() { if let Some(select) = crate::binding::dml_scope(expression) { let query = Expression::Select(Box::new(select)); - let scope = build_scope(&query); + let mut scope = build_scope(&query); validate_scope_tree( - &scope, + ValidationScope::Owned(&mut scope), &[], schema_map, resolver_schema, options, &mut errors, bindings, + None, ); let target_columns = match expression { Expression::Insert(insert) => { @@ -4345,15 +4473,16 @@ fn validate_statement_with_schema( // ordinary source validation. let mut source = query.clone(); attach_query_with_clause(&mut source, insert.with.as_ref()); - let scope = build_scope(&source); + let mut scope = build_scope(&source); validate_scope_tree( - &scope, + ValidationScope::Owned(&mut scope), &[], schema_map, resolver_schema, options, &mut errors, bindings, + None, ); } } @@ -4429,15 +4558,18 @@ fn validate_statement_with_schema( | Expression::Intersect(_) | Expression::Except(_) ) { - let scope = build_scope(expression); + let mut scope = build_scope(expression); validate_scope_tree( - &scope, + ValidationScope::Owned(&mut scope), &[], schema_map, resolver_schema, options, &mut errors, bindings, + semantics + .as_deref_mut() + .filter(|_| std::ptr::eq(expression, stmt)), ); } else { // DDL may declare a new table; only its query inputs are references. @@ -4539,8 +4671,22 @@ pub fn validate_parsed_with_schema( } for mut statement in statements { + // A query's per-SELECT semantic checks reuse the reference scope tree. + // Other statements, such as DML with synthetic scopes, keep the + // standalone traversal. + let fused_semantics = options.semantic + && matches!( + statement, + Expression::Select(_) + | Expression::Union(_) + | Expression::Intersect(_) + | Expression::Except(_) + ); + let mut semantic_errors = Vec::new(); if options.semantic { - all_errors.extend(check_semantics(&statement, dialect, Some(schema))); + if !fused_semantics { + semantic_errors = check_semantics(&statement, dialect, Some(schema)); + } if !options.check_types { for node in statement.dfs() { if let Expression::Function(function) = node { @@ -4554,24 +4700,56 @@ pub fn validate_parsed_with_schema( function.args.len(), dialect, strict, - &mut all_errors, + &mut semantic_errors, ); check_function_catalog( function, dialect, effective_function_catalog, strict, - &mut all_errors, + &mut semantic_errors, ); } } } } } + // One traversal of a fused query collects its statement-level + // semantic findings, SELECT count, projection stars, and lambdas. + // Lambda binding does not change query outputs or SELECTs. + let mut statement_semantics = None; + let mut projection_star = None; + let mut has_lambda = true; + if fused_semantics { + let mut errors = Vec::new(); + let mut selects = 0; + let mut star = false; + let mut lambda = false; + for node in statement.dfs() { + lambda = lambda || matches!(node, Expression::Lambda(_)); + if semantics::check_set_operation_order(node, dialect, &mut errors) { + selects += 1; + star = star + || matches!(node, Expression::Select(select) if select_projects_star(select)); + } + } + statement_semantics = Some((errors, selects)); + projection_star = Some(star); + has_lambda = lambda; + } crate::binding::bind_dml_pseudoreferences(&mut statement); - let statement = bind_validation_lambdas(statement, dialect); + if has_lambda { + statement = bind_validation_lambdas(statement, dialect); + } + let statement = statement; let mut bindings = ProjectionAliasBindings::new(); - all_errors.extend(validate_statement_with_schema( + let mut scope_semantics = fused_semantics.then(|| SemanticScopeChecks { + schema, + mapping: &resolver_schema, + errors: Vec::new(), + selects: 0, + }); + let reference_errors = validate_statement_with_schema( &statement, &schema_map, &resolver_schema, @@ -4582,7 +4760,26 @@ pub fn validate_parsed_with_schema( check_types: options.check_types, }, &mut bindings, - )); + scope_semantics.as_mut(), + ); + if let (Some(checks), Some((mut errors, selects))) = (scope_semantics, statement_semantics) + { + if selects == checks.selects { + errors.extend(checks.errors); + } else { + // A SELECT outside the scope tree must still be checked. + errors = semantics::check_bound_semantics( + &statement, + dialect, + Some(schema), + &resolver_schema, + ); + } + errors.append(&mut semantic_errors); + semantic_errors = errors; + } + all_errors.extend(semantic_errors); + all_errors.extend(reference_errors); let mut statement = apply_projection_alias_bindings(statement, &bindings); if options.check_types { // Give the type checker star-sourced column types for every query. @@ -4590,7 +4787,7 @@ pub fn validate_parsed_with_schema( // qualification is only needed for unresolved stars that another scope // consumes, such as a star over a derived table inside a subquery. // Open/partial schemas remain conservative. - if has_projection_star(&statement, false) { + if projection_star.unwrap_or_else(|| has_projection_star(&statement, false)) { crate::lineage::expand_cte_stars( &mut statement, Some(&resolver_schema as &dyn crate::schema::Schema), @@ -4645,19 +4842,23 @@ fn has_projection_star(statement: &Expression, consumed_only: bool) -> bool { statement.dfs().any(|node| { matches!(node, Expression::Select(select) if root != Some(select.as_ref() as *const crate::expressions::Select) - && select.expressions.iter().any(|projection| { - let mut projection = projection; - loop { - match projection { - Expression::Alias(alias) => projection = &alias.this, - Expression::Annotated(annotated) => projection = &annotated.this, - Expression::Paren(paren) => projection = &paren.this, - _ => break, - } + && select_projects_star(select)) + }) +} + +fn select_projects_star(select: &crate::expressions::Select) -> bool { + select.expressions.iter().any(|projection| { + let mut projection = projection; + loop { + match projection { + Expression::Alias(alias) => projection = &alias.this, + Expression::Annotated(annotated) => projection = &annotated.this, + Expression::Paren(paren) => projection = &paren.this, + _ => break, } - matches!(projection, Expression::Star(_)) - || matches!(projection, Expression::Column(column) if column.name.name == "*") - })) + } + matches!(projection, Expression::Star(_)) + || matches!(projection, Expression::Column(column) if column.name.name == "*") }) } @@ -4679,7 +4880,7 @@ pub(crate) fn validate_project_query_scope( } let mut bindings = ProjectionAliasBindings::new(); validate_scope_tree( - scope, + ValidationScope::Shared(scope), &[], &schema_map, &resolver_schema, @@ -4691,6 +4892,7 @@ pub(crate) fn validate_project_query_scope( }, &mut errors, &mut bindings, + None, ); if options.check_references { let statement = apply_projection_alias_bindings(scope.expression.clone(), &bindings); diff --git a/crates/polyglot-sql/src/validation/semantics.rs b/crates/polyglot-sql/src/validation/semantics.rs index 51f883a0..2b79c2a1 100644 --- a/crates/polyglot-sql/src/validation/semantics.rs +++ b/crates/polyglot-sql/src/validation/semantics.rs @@ -386,11 +386,12 @@ fn source_identifier(expr: &Expression) -> Option<&Identifier> { fn check_structure( node: &Expression, select: &Select, + with: Option<&crate::expressions::With>, dialect: DialectType, errors: &mut Vec, ) { let key = |name: &Identifier| crate::set_operation::identifier_key(name, Some(dialect)); - if let Some(with) = &select.with { + if let Some(with) = with { let mut names = HashSet::new(); for cte in &with.ctes { if dialect == DialectType::Snowflake && !cte.columns.is_empty() { @@ -564,135 +565,189 @@ pub(crate) fn check_semantics( } else { stmt }; - let mut errors = Vec::new(); let mapping = schema.map(|schema| mapping_schema_from_validation_schema_with_dialect(schema, dialect)); let empty_schema = MappingSchema::with_dialect(dialect); + check_bound_semantics( + stmt, + dialect, + schema, + mapping.as_ref().unwrap_or(&empty_schema), + ) +} + +/// `check_semantics` over a statement whose lambdas are already bound. +pub(super) fn check_bound_semantics( + stmt: &Expression, + dialect: DialectType, + schema: Option<&ValidationSchema>, + mapping: &MappingSchema, +) -> Vec { + let mut errors = Vec::new(); for node in stmt.dfs() { - let set_order = match node { - Expression::Union(query) => query.order_by.as_ref(), - Expression::Intersect(query) => query.order_by.as_ref(), - Expression::Except(query) => query.order_by.as_ref(), - _ => None, - }; - if let Some(order) = set_order.filter(|order| !is_order_by_all(order, dialect)) { - if let Ok(outputs) = crate::set_operation::query_output_identifiers(node, Some(dialect)) - { - for ordered in &order.expressions { - if let Expression::Column(column) = &ordered.this { - if !outputs.iter().any(|name| { - crate::set_operation::identifier_key(name, Some(dialect)) - == crate::set_operation::identifier_key(&column.name, Some(dialect)) - }) { - errors.push(issue( - &ordered.this, - "E201", - "Set-operation ORDER BY references a non-output column", - )); - } - } - } - } - } + check_set_operation_order(node, dialect, &mut errors); let Expression::Select(select) = node else { continue; }; - check_structure(node, select, dialect, &mut errors); - if !select - .expressions - .iter() - .any(|expr| expr.dfs().any(|node| matches!(node, Expression::Star(_) | Expression::BracedWildcard(_)) || matches!(node, Expression::Function(f) if f.name.eq_ignore_ascii_case("columns")))) - { - for ordered in select.order_by.iter().filter(|order| !is_order_by_all(order, dialect)).flat_map(|order| &order.expressions) { - if let Expression::Literal(literal) = &ordered.this { - if let crate::expressions::Literal::Number(number) = literal.as_ref() { - if number.parse::().is_ok_and(|position| { - position == 0 || position > select.expressions.len() - }) { - errors.push(issue( - &ordered.this, - "E201", - "ORDER BY position is outside the output column range", - )); - } + let scope = selected_validation_scope(&build_scope(node)); + check_select_semantics( + node, + select, + select.with.as_ref(), + &scope, + dialect, + schema, + mapping, + &mut errors, + ); + } + errors +} + +/// Set-operation checks that do not depend on a SELECT scope. Returns whether +/// `node` is a SELECT, so callers can account for per-SELECT checks. +pub(super) fn check_set_operation_order( + node: &Expression, + dialect: DialectType, + errors: &mut Vec, +) -> bool { + let set_order = match node { + Expression::Union(query) => query.order_by.as_ref(), + Expression::Intersect(query) => query.order_by.as_ref(), + Expression::Except(query) => query.order_by.as_ref(), + _ => return matches!(node, Expression::Select(_)), + }; + if let Some(order) = set_order.filter(|order| !is_order_by_all(order, dialect)) { + if let Ok(outputs) = crate::set_operation::query_output_identifiers(node, Some(dialect)) { + for ordered in &order.expressions { + if let Expression::Column(column) = &ordered.this { + if !outputs.iter().any(|name| { + crate::set_operation::identifier_key(name, Some(dialect)) + == crate::set_operation::identifier_key(&column.name, Some(dialect)) + }) { + errors.push(issue( + &ordered.this, + "E201", + "Set-operation ORDER BY references a non-output column", + )); } } } } - let scope = selected_validation_scope(&build_scope(node)); - let mut resolver = Resolver::new(&scope, mapping.as_ref().unwrap_or(&empty_schema), true); - let mut input_names = HashSet::new(); - let mut input_column_names = HashSet::new(); - let mut open = false; - for source in scope.sources.keys() { - let columns = resolver.get_source_columns(source).unwrap_or_default(); - input_column_names.extend(columns.iter().cloned()); - open |= columns.is_empty() || columns.iter().any(|column| column == "*"); - input_names.extend(columns.iter().map(|column| { - crate::set_operation::identifier_key( - &crate::binding::schema_identifier(column), - Some(dialect), - ) - })); - } - let mut left_columns = HashSet::new(); - let mut left_open = false; - for source in select.from.iter().flat_map(|from| &from.expressions) { - if let Some(name) = source_identifier(source) { - let columns = resolver.get_source_columns(&name.name).unwrap_or_default(); - left_open |= columns.is_empty() || columns.iter().any(|column| column == "*"); - left_columns.extend(columns.into_iter().map(|column| lower(&column))); - } else { - left_open = true; - } - } - for join in &select.joins { - if let Some(name) = source_identifier(&join.this) { - let right = resolver.get_source_columns(&name.name).unwrap_or_default(); - let right_open = right.is_empty() || right.iter().any(|column| column == "*"); - for column in &join.using { - if (!left_open && !left_columns.contains(&lower(&column.name))) - || (!right_open - && !right - .iter() - .any(|name| name.eq_ignore_ascii_case(&column.name))) + } + false +} + +/// Scope-local checks for one SELECT. `scope` is its selected reference scope: +/// the SELECT's own sources, with CTE definitions visible at that point. `with` +/// is the SELECT's own WITH clause, which `select` itself may no longer carry. +#[allow(clippy::too_many_arguments)] +pub(super) fn check_select_semantics( + node: &Expression, + select: &Select, + with: Option<&crate::expressions::With>, + scope: &crate::scope::Scope, + dialect: DialectType, + schema: Option<&ValidationSchema>, + mapping: &MappingSchema, + errors: &mut Vec, +) { + check_structure(node, select, with, dialect, errors); + if !select.expressions.iter().any(|expr| { + expr.dfs().any(|node| { + matches!(node, Expression::Star(_) | Expression::BracedWildcard(_)) + || matches!(node, Expression::Function(f) if f.name.eq_ignore_ascii_case("columns")) + }) + }) { + for ordered in select + .order_by + .iter() + .filter(|order| !is_order_by_all(order, dialect)) + .flat_map(|order| &order.expressions) + { + if let Expression::Literal(literal) = &ordered.this { + if let crate::expressions::Literal::Number(number) = literal.as_ref() { + if number + .parse::() + .is_ok_and(|position| position == 0 || position > select.expressions.len()) { errors.push(issue( - node, + &ordered.this, "E201", - &format!( - "JOIN USING column '{}' must exist on both sides", - column.name - ), + "ORDER BY position is outside the output column range", )); } } - left_columns.extend(right.into_iter().map(|column| lower(&column))); - left_open |= right_open; - } else { - left_open = true; } } - for projection in &select.expressions { - if dialect == DialectType::DuckDB && !open { - for expr in walk_in_scope(projection, false) { - if let Expression::Function(function) = expr { - if function.name.eq_ignore_ascii_case("columns") { - if let Some(Expression::Literal(literal)) = function.args.first() { - if let crate::expressions::Literal::String(pattern) = - literal.as_ref() - { - if let Ok(regex) = regex::Regex::new(pattern) { - if !input_column_names - .iter() - .any(|name| regex.is_match(name)) - { - errors.push(issue( - expr, - "E201", - "COLUMNS pattern matches no input columns", - )); - } + } + let mut resolver = Resolver::new(scope, mapping, true); + let mut input_names = HashSet::new(); + let mut input_column_names = HashSet::new(); + let mut open = false; + for source in scope.sources.keys() { + let columns = resolver.get_source_columns(source).unwrap_or_default(); + input_column_names.extend(columns.iter().cloned()); + open |= columns.is_empty() || columns.iter().any(|column| column == "*"); + input_names.extend(columns.iter().map(|column| { + crate::set_operation::identifier_key( + &crate::binding::schema_identifier(column), + Some(dialect), + ) + })); + } + let mut left_columns = HashSet::new(); + let mut left_open = false; + for source in select.from.iter().flat_map(|from| &from.expressions) { + if let Some(name) = source_identifier(source) { + let columns = resolver.get_source_columns(&name.name).unwrap_or_default(); + left_open |= columns.is_empty() || columns.iter().any(|column| column == "*"); + left_columns.extend(columns.into_iter().map(|column| lower(&column))); + } else { + left_open = true; + } + } + for join in &select.joins { + if let Some(name) = source_identifier(&join.this) { + let right = resolver.get_source_columns(&name.name).unwrap_or_default(); + let right_open = right.is_empty() || right.iter().any(|column| column == "*"); + for column in &join.using { + if (!left_open && !left_columns.contains(&lower(&column.name))) + || (!right_open + && !right + .iter() + .any(|name| name.eq_ignore_ascii_case(&column.name))) + { + errors.push(issue( + node, + "E201", + &format!( + "JOIN USING column '{}' must exist on both sides", + column.name + ), + )); + } + } + left_columns.extend(right.into_iter().map(|column| lower(&column))); + left_open |= right_open; + } else { + left_open = true; + } + } + for projection in &select.expressions { + if dialect == DialectType::DuckDB && !open { + for expr in walk_in_scope(projection, false) { + if let Expression::Function(function) = expr { + if function.name.eq_ignore_ascii_case("columns") { + if let Some(Expression::Literal(literal)) = function.args.first() { + if let crate::expressions::Literal::String(pattern) = literal.as_ref() { + if let Ok(regex) = regex::Regex::new(pattern) { + if !input_column_names.iter().any(|name| regex.is_match(name)) { + errors.push(issue( + expr, + "E201", + "COLUMNS pattern matches no input columns", + )); } } } @@ -700,230 +755,228 @@ pub(crate) fn check_semantics( } } } - if let Expression::Star(star) = projection { - if let Some(table) = &star.table { - if resolve_scope_source_name(&scope, &table.name).is_none() { + } + if let Expression::Star(star) = projection { + if let Some(table) = &star.table { + if resolve_scope_source_name(scope, &table.name).is_none() { + errors.push(issue( + projection, + "E200", + "Qualified star references an unknown table alias", + )); + } + } + if !open { + for name in star + .except + .iter() + .flatten() + .chain(star.replace.iter().flatten().map(|alias| &alias.alias)) + { + let key = crate::set_operation::identifier_key(name, Some(dialect)); + if !input_names.contains(&key) { errors.push(issue( projection, - "E200", - "Qualified star references an unknown table alias", + "E201", + &format!("Star modifier references unknown column '{}'", name.name), )); } } - if !open { - for name in star - .except - .iter() - .flatten() - .chain(star.replace.iter().flatten().map(|alias| &alias.alias)) - { - let key = crate::set_operation::identifier_key(name, Some(dialect)); - if !input_names.contains(&key) { - errors.push(issue( - projection, - "E201", - &format!("Star modifier references unknown column '{}'", name.name), - )); - } - } - } } } - let no_aliases = HashMap::new(); - let mut aliases = HashMap::new(); - let mut duplicate = HashSet::new(); - for projection in &select.expressions { - placement( - projection, - true, - true, - if !open && crate::binding::lateral_aliases(dialect) { - &aliases - } else { - &no_aliases - }, - dialect, - &mut errors, - ); - if let Expression::Alias(alias) = projection { - let key = crate::set_operation::identifier_key(&alias.alias, Some(dialect)); - if matches!(&alias.this, Expression::Column(column) if column.table.is_none() && crate::set_operation::identifier_key(&column.name, Some(dialect)) == key) - { - continue; - } - if !input_names.contains(&key) - && !duplicate.contains(&key) - && aliases.insert(key.clone(), &alias.this).is_some() - { - aliases.remove(&key); - duplicate.insert(key); - } + } + let no_aliases = HashMap::new(); + let mut aliases = HashMap::new(); + let mut duplicate = HashSet::new(); + for projection in &select.expressions { + placement( + projection, + true, + true, + if !open && crate::binding::lateral_aliases(dialect) { + &aliases + } else { + &no_aliases + }, + dialect, + errors, + ); + if let Expression::Alias(alias) = projection { + let key = crate::set_operation::identifier_key(&alias.alias, Some(dialect)); + if matches!(&alias.this, Expression::Column(column) if column.table.is_none() && crate::set_operation::identifier_key(&column.name, Some(dialect)) == key) + { + continue; } - } - // Incomplete source metadata cannot prove that a name refers to an - // output alias rather than an input column for placement diagnostics. - let placement_aliases = if open { &no_aliases } else { &aliases }; - use crate::binding::{clause_aliases, AliasClause}; - for expression in select - .prewhere - .iter() - .chain(select.where_clause.iter().map(|c| &c.this)) - { - placement( - expression, - false, - false, - if clause_aliases(dialect, AliasClause::Where) { - placement_aliases - } else { - &no_aliases - }, - dialect, - &mut errors, - ); - } - for join in &select.joins { - if let Some(on) = &join.on { - placement(on, false, false, &no_aliases, dialect, &mut errors); + if !input_names.contains(&key) + && !duplicate.contains(&key) + && aliases.insert(key.clone(), &alias.this).is_some() + { + aliases.remove(&key); + duplicate.insert(key); } } - if let Some(group) = &select.group_by { - for e in &group.expressions { - let e = match e { - Expression::Literal(literal) => literal - .value_str() - .parse::() - .ok() - .and_then(|i| i.checked_sub(1)) - .and_then(|i| select.expressions.get(i)) - .unwrap_or(e), - _ => e, - }; - placement( - e, - false, - false, - if clause_aliases(dialect, AliasClause::Group) { - placement_aliases - } else { - &no_aliases - }, - dialect, - &mut errors, - ); - } + } + // Incomplete source metadata cannot prove that a name refers to an + // output alias rather than an input column for placement diagnostics. + let placement_aliases = if open { &no_aliases } else { &aliases }; + use crate::binding::{clause_aliases, AliasClause}; + for expression in select + .prewhere + .iter() + .chain(select.where_clause.iter().map(|c| &c.this)) + { + placement( + expression, + false, + false, + if clause_aliases(dialect, AliasClause::Where) { + placement_aliases + } else { + &no_aliases + }, + dialect, + errors, + ); + } + for join in &select.joins { + if let Some(on) = &join.on { + placement(on, false, false, &no_aliases, dialect, errors); } - if let Some(having) = &select.having { + } + if let Some(group) = &select.group_by { + for e in &group.expressions { + let e = match e { + Expression::Literal(literal) => literal + .value_str() + .parse::() + .ok() + .and_then(|i| i.checked_sub(1)) + .and_then(|i| select.expressions.get(i)) + .unwrap_or(e), + _ => e, + }; placement( - &having.this, - true, + e, false, - if clause_aliases(dialect, AliasClause::Having) { - placement_aliases - } else { - &no_aliases - }, - dialect, - &mut errors, - ); - } - if let Some(qualify) = &select.qualify { - placement( - &qualify.this, - true, - true, - if clause_aliases(dialect, AliasClause::Qualify) { + false, + if clause_aliases(dialect, AliasClause::Group) { placement_aliases } else { &no_aliases }, dialect, - &mut errors, + errors, ); } - if let Some(order) = select - .order_by - .as_ref() - .filter(|order| !is_order_by_all(order, dialect)) - { - for e in &order.expressions { - placement(&e.this, true, true, placement_aliases, dialect, &mut errors); - } - } - let sources = scope - .sources - .keys() - .map(|name| { - crate::set_operation::identifier_key( - &crate::binding::schema_identifier(name), - Some(dialect), - ) - }) - .collect(); - grouping( - select, + } + if let Some(having) = &select.having { + placement( + &having.this, + true, + false, + if clause_aliases(dialect, AliasClause::Having) { + placement_aliases + } else { + &no_aliases + }, dialect, - schema, - &aliases, - &mut resolver, - &sources, - &mut errors, + errors, ); - if let Some(star) = select - .expressions - .iter() - .find(|e| matches!(e, Expression::Star(_))) - { - let mut warning = issue(star, "W001", "SELECT * is discouraged; specify columns explicitly for better performance and maintainability"); - warning.severity = crate::ValidationSeverity::Warning; - errors.push(warning); + } + if let Some(qualify) = &select.qualify { + placement( + &qualify.this, + true, + true, + if clause_aliases(dialect, AliasClause::Qualify) { + placement_aliases + } else { + &no_aliases + }, + dialect, + errors, + ); + } + if let Some(order) = select + .order_by + .as_ref() + .filter(|order| !is_order_by_all(order, dialect)) + { + for e in &order.expressions { + placement(&e.this, true, true, placement_aliases, dialect, errors); } - if select.distinct + } + let sources = scope + .sources + .keys() + .map(|name| { + crate::set_operation::identifier_key( + &crate::binding::schema_identifier(name), + Some(dialect), + ) + }) + .collect(); + grouping( + select, + dialect, + schema, + &aliases, + &mut resolver, + &sources, + errors, + ); + if let Some(star) = select + .expressions + .iter() + .find(|e| matches!(e, Expression::Star(_))) + { + let mut warning = issue(star, "W001", "SELECT * is discouraged; specify columns explicitly for better performance and maintainability"); + warning.severity = crate::ValidationSeverity::Warning; + errors.push(warning); + } + if select.distinct + && select + .order_by + .as_ref() + .is_some_and(|order| !is_order_by_all(order, dialect)) + { + if dialect == DialectType::Snowflake && select - .order_by - .as_ref() - .is_some_and(|order| !is_order_by_all(order, dialect)) + .expressions + .iter() + .all(|expr| matches!(unalias(expr), Expression::Column(_))) { - if dialect == DialectType::Snowflake - && select - .expressions - .iter() - .all(|expr| matches!(unalias(expr), Expression::Column(_))) - { - let key = - |name: &Identifier| crate::set_operation::identifier_key(name, Some(dialect)); - let mut selected = HashSet::new(); - for expr in &select.expressions { - if let Expression::Alias(alias) = expr { - selected.insert(key(&alias.alias)); - } - if let Expression::Column(column) = unalias(expr) { - selected.insert(key(&column.name)); - } + let key = |name: &Identifier| crate::set_operation::identifier_key(name, Some(dialect)); + let mut selected = HashSet::new(); + for expr in &select.expressions { + if let Expression::Alias(alias) = expr { + selected.insert(key(&alias.alias)); } - for ordered in select.order_by.iter().flat_map(|order| &order.expressions) { - if let Expression::Column(column) = &ordered.this { - if !selected.contains(&key(&column.name)) { - errors.push(issue( - &ordered.this, - validation_codes::E_UNKNOWN_COLUMN, - "DISTINCT ORDER BY references a non-selected column", - )); - } + if let Expression::Column(column) = unalias(expr) { + selected.insert(key(&column.name)); + } + } + for ordered in select.order_by.iter().flat_map(|order| &order.expressions) { + if let Expression::Column(column) = &ordered.this { + if !selected.contains(&key(&column.name)) { + errors.push(issue( + &ordered.this, + validation_codes::E_UNKNOWN_COLUMN, + "DISTINCT ORDER BY references a non-selected column", + )); } } } - errors.push(ValidationError::warning( - "DISTINCT with ORDER BY: ensure ORDER BY columns are in SELECT list", - "W003", - )); - } - if select.limit.is_some() && select.order_by.is_none() { - errors.push(ValidationError::warning( - "LIMIT without ORDER BY produces non-deterministic results", - "W004", - )); } + errors.push(ValidationError::warning( + "DISTINCT with ORDER BY: ensure ORDER BY columns are in SELECT list", + "W003", + )); + } + if select.limit.is_some() && select.order_by.is_none() { + errors.push(ValidationError::warning( + "LIMIT without ORDER BY produces non-deterministic results", + "W004", + )); } - errors } diff --git a/crates/polyglot-sql/src/validation/tests.rs b/crates/polyglot-sql/src/validation/tests.rs index dc5f8e29..7a344dc1 100644 --- a/crates/polyglot-sql/src/validation/tests.rs +++ b/crates/polyglot-sql/src/validation/tests.rs @@ -1203,6 +1203,71 @@ fn star_sourced_types_are_checked_without_set_operations() { ); } +#[test] +fn semantic_checks_resolve_sibling_ctes_in_cte_bodies() { + // The physical `a` shows that the CTE, not the same-named table, is bound. + let schema: ValidationSchema = serde_json::from_value(serde_json::json!({"tables":[ + {"name":"orders","columns":[{"name":"id","type":"INTEGER"},{"name":"amount","type":"INTEGER"}]}, + {"name":"a","columns":[{"name":"x","type":"INTEGER"}]} + ]})) + .unwrap(); + let options = SchemaValidationOptions { + semantic: true, + check_types: true, + ..Default::default() + }; + let invalid = validate_with_schema( + "WITH a AS (SELECT id, amount FROM orders), b AS (SELECT * EXCLUDE (missing) FROM a) SELECT * FROM b", + DialectType::DuckDB, + &schema, + &options, + ); + assert!( + invalid + .errors + .iter() + .any(|error| error.code == "E201" && error.message.contains("'missing'")), + "{:?}", + invalid.errors + ); + let valid = validate_with_schema( + "WITH a AS (SELECT id, amount FROM orders), b AS (SELECT * EXCLUDE (id) FROM a) SELECT * FROM b", + DialectType::DuckDB, + &schema, + &options, + ); + assert!(valid.valid, "{:?}", valid.errors); +} + +#[test] +fn semantic_checks_cover_every_select_once() { + let schema = semantic_type_schema(); + let options = SchemaValidationOptions { + semantic: true, + ..Default::default() + }; + // Nested aggregates in a CTE, a derived table, a scalar subquery and a + // set-operation branch are each reported exactly once. + let result = validate_with_schema( + "WITH q AS (SELECT SUM(SUM(i)) AS t FROM orders) \ + SELECT (SELECT SUM(SUM(i)) FROM orders) FROM (SELECT SUM(SUM(i)) AS t FROM orders) AS d \ + UNION ALL SELECT SUM(SUM(i)) FROM orders", + DialectType::DuckDB, + &schema, + &options, + ); + assert_eq!( + result + .errors + .iter() + .filter(|error| error.code == "E231") + .count(), + 4, + "{:?}", + result.errors + ); +} + fn semantic_type_schema() -> ValidationSchema { serde_json::from_value(serde_json::json!({"tables":[{"name":"orders","columns":[ {"name":"i","type":"INTEGER"}, {"name":"s","type":"VARCHAR"}, diff --git a/examples/rust/Cargo.lock b/examples/rust/Cargo.lock index bb7e426f..4e864330 100644 --- a/examples/rust/Cargo.lock +++ b/examples/rust/Cargo.lock @@ -71,7 +71,7 @@ dependencies = [ [[package]] name = "polyglot-sql" -version = "0.13.7" +version = "0.13.8" dependencies = [ "polyglot-sql-ast-derive", "polyglot-sql-function-catalogs", @@ -85,7 +85,7 @@ dependencies = [ [[package]] name = "polyglot-sql-ast-derive" -version = "0.13.7" +version = "0.13.8" dependencies = [ "proc-macro2", "quote", @@ -101,7 +101,7 @@ dependencies = [ [[package]] name = "polyglot-sql-function-catalogs" -version = "0.13.7" +version = "0.13.8" [[package]] name = "proc-macro2" diff --git a/examples/rust/Cargo.toml b/examples/rust/Cargo.toml index 82d50ea2..8777e338 100644 --- a/examples/rust/Cargo.toml +++ b/examples/rust/Cargo.toml @@ -5,4 +5,4 @@ edition = "2021" publish = false [dependencies] -polyglot-sql = { version = "0.13.7", path = "../../crates/polyglot-sql" } +polyglot-sql = { version = "0.13.8", path = "../../crates/polyglot-sql" } diff --git a/packages/documentation/package.json b/packages/documentation/package.json index 5df851a1..32e44d84 100644 --- a/packages/documentation/package.json +++ b/packages/documentation/package.json @@ -1,6 +1,6 @@ { "name": "@polyglot-sql/documentation", - "version": "0.13.7", + "version": "0.13.8", "private": true, "type": "module", "scripts": { diff --git a/packages/go/README.md b/packages/go/README.md index 873839a0..5d9858d9 100644 --- a/packages/go/README.md +++ b/packages/go/README.md @@ -21,7 +21,7 @@ go get github.com/tobilg/polyglot/packages/go ``` Go module releases use nested tags that match the root Polyglot release, for -example `packages/go/v0.13.7`. +example `packages/go/v0.13.8`. ## Native Library Setup diff --git a/packages/go/types.go b/packages/go/types.go index 84ce5d14..b5d581ad 100644 --- a/packages/go/types.go +++ b/packages/go/types.go @@ -2,7 +2,7 @@ package polyglot import "encoding/json" -const sdkVersion = "0.13.7" +const sdkVersion = "0.13.8" func Version() string { return sdkVersion diff --git a/packages/playground/package.json b/packages/playground/package.json index 5a52a05b..a70e0e08 100644 --- a/packages/playground/package.json +++ b/packages/playground/package.json @@ -1,6 +1,6 @@ { "name": "@polyglot-sql/playground", - "version": "0.13.7", + "version": "0.13.8", "private": true, "type": "module", "scripts": { diff --git a/packages/python-docs/package.json b/packages/python-docs/package.json index f8657aa7..498346f9 100644 --- a/packages/python-docs/package.json +++ b/packages/python-docs/package.json @@ -1,6 +1,6 @@ { "name": "@polyglot-sql/python-docs", - "version": "0.13.7", + "version": "0.13.8", "private": true, "type": "module", "scripts": { diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 45607e62..347ebe5e 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@polyglot-sql/sdk", - "version": "0.13.7", + "version": "0.13.8", "description": "SQL dialect translator powered by WebAssembly", "type": "module", "main": "./dist/index.cjs",