diff --git a/Makefile b/Makefile index ef7d6133ad..b8d25c48b4 100644 --- a/Makefile +++ b/Makefile @@ -95,6 +95,7 @@ E2E_DUCKDB_BUILD_INCREMENTAL_PATHS := \ tests/e2e/src/sqlbuild/cli/commands/main/build/test_microbatch_replay_backfill_lifecycle.py \ tests/e2e/src/sqlbuild/cli/commands/main/build/test_mixed_timestamp_grain_replay.py \ tests/e2e/src/sqlbuild/cli/commands/main/build/test_model_backed_cursor_build.py \ + tests/e2e/src/sqlbuild/cli/commands/main/build/test_replay_on_change_full_build.py \ tests/e2e/src/sqlbuild/cli/commands/main/build/test_seed_watermark_build.py \ tests/e2e/src/sqlbuild/cli/commands/main/build/test_snapshot_build.py diff --git a/crates/sqlbuild-rules-native/src/bindings/_helpers/functions.rs b/crates/sqlbuild-rules-native/src/bindings/_helpers/functions.rs index 817bc8f596..6ecc7baca1 100644 --- a/crates/sqlbuild-rules-native/src/bindings/_helpers/functions.rs +++ b/crates/sqlbuild-rules-native/src/bindings/_helpers/functions.rs @@ -121,6 +121,24 @@ fn plan_and_render_sql_tests_json(py: Python<'_>, request_json: &str) -> PyResul .map_err(value_error) } +#[pyfunction] +fn resolve_sql_test_chains_json(py: Python<'_>, request_json: &str) -> PyResult { + py.detach(|| { + crate::compiler::main::sql_test_chain_resolution::resolve_chains_json(request_json) + }) + .map_err(value_error) +} + +#[pyfunction] +fn render_sql_test_difference_sample_json(py: Python<'_>, request_json: &str) -> PyResult { + py.detach(|| { + crate::compiler::main::sql_test_difference_sampling::render_difference_sample_json( + request_json, + ) + }) + .map_err(value_error) +} + #[pyfunction] fn extract_sql_tests_json(py: Python<'_>, request_json: &str) -> PyResult { py.detach(|| crate::compiler::main::sql_test_extraction::extract_batch_json(request_json)) @@ -321,6 +339,11 @@ pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { )?)?; module.add_function(wrap_pyfunction!(render_sql_test_comparisons_json, module)?)?; module.add_function(wrap_pyfunction!(plan_and_render_sql_tests_json, module)?)?; + module.add_function(wrap_pyfunction!(resolve_sql_test_chains_json, module)?)?; + module.add_function(wrap_pyfunction!( + render_sql_test_difference_sample_json, + module + )?)?; module.add_function(wrap_pyfunction!(extract_sql_tests_json, module)?)?; module.add_function(wrap_pyfunction!(parse_model_headers, module)?)?; module.add_function(wrap_pyfunction!(tokenize_model_header, module)?)?; diff --git a/crates/sqlbuild-rules-native/src/compiler/_helpers/sql_tests/expected_columns.rs b/crates/sqlbuild-rules-native/src/compiler/_helpers/sql_tests/expected_columns.rs new file mode 100644 index 0000000000..3a021b67b6 --- /dev/null +++ b/crates/sqlbuild-rules-native/src/compiler/_helpers/sql_tests/expected_columns.rs @@ -0,0 +1,143 @@ +//! Expected-output column names read syntactically from an `__expected__` CTE body. + +use std::collections::HashSet; + +use polyglot_sql::expressions::{Identifier, Select}; +use polyglot_sql::{Dialect, Expression}; + +/// Return the authored output column identifiers of `sql`, or `None` when they are not explicit. +pub(crate) fn expected_columns(sql: &str, dialect: &Dialect) -> Option> { + let mut statements = match dialect.parse(sql) { + Ok(statements) => statements, + Err(_) => return None, + }; + if statements.len() != 1 { + return None; + } + let statement = statements.pop()?; + let source = AuthoredSource::new(sql); + let columns: Vec = query_columns(&statement, &source)?; + let mut seen: HashSet = HashSet::new(); + if columns.is_empty() + || !columns + .iter() + .all(|column| seen.insert(normalized_identifier(column))) + { + return None; + } + Some(columns) +} + +/// Fold an authored identifier to the case-insensitive name used for column matching. +pub(crate) fn normalized_identifier(identifier: &str) -> String { + let unquoted = match identifier.chars().next() { + Some('"') => identifier.trim_matches('"').replace("\"\"", "\""), + Some('`') => identifier.trim_matches('`').replace("``", "`"), + Some('[') => identifier + .trim_start_matches('[') + .trim_end_matches(']') + .replace("]]", "]"), + _ => identifier.to_string(), + }; + unquoted.to_lowercase() +} + +struct AuthoredSource<'a> { + sql: &'a str, + char_offsets: Vec, +} + +impl<'a> AuthoredSource<'a> { + fn new(sql: &'a str) -> Self { + let mut char_offsets: Vec = sql.char_indices().map(|(offset, _)| offset).collect(); + char_offsets.push(sql.len()); + Self { sql, char_offsets } + } + + /// Return the identifier exactly as written, including its quoting. + fn identifier(&self, identifier: &Identifier) -> Option { + let Some(span) = identifier.span.as_ref() else { + return (!identifier.quoted).then(|| identifier.name.clone()); + }; + let start = *self.char_offsets.get(span.start)?; + let end = *self.char_offsets.get(span.end)?; + let text = self.sql.get(start..end)?; + let consistent = if identifier.quoted { + text.chars().count() >= identifier.name.chars().count() + 2 + } else { + text == identifier.name + }; + consistent.then(|| text.to_string()) + } +} + +fn query_columns(expression: &Expression, source: &AuthoredSource<'_>) -> Option> { + match expression { + Expression::Select(select) => select_columns(select, source), + Expression::Union(union) => query_columns(&union.left, source), + Expression::Intersect(intersect) => query_columns(&intersect.left, source), + Expression::Except(except) => query_columns(&except.left, source), + Expression::Paren(paren) => query_columns(&paren.this, source), + Expression::Subquery(subquery) if subquery.column_aliases.is_empty() => { + query_columns(&subquery.this, source) + } + Expression::Values(values) => identifiers(&values.column_aliases, source), + _ => None, + } +} + +fn select_columns(select: &Select, source: &AuthoredSource<'_>) -> Option> { + if let [Expression::Star(star)] = select.expressions.as_slice() { + if star.table.is_some() + || star.except.is_some() + || star.replace.is_some() + || star.rename.is_some() + || !select.joins.is_empty() + { + return None; + } + let from = select.from.as_ref()?; + let [relation] = from.expressions.as_slice() else { + return None; + }; + return aliased_values_columns(relation, source); + } + select + .expressions + .iter() + .map(|projection| projection_column(projection, source)) + .collect() +} + +fn aliased_values_columns( + relation: &Expression, + source: &AuthoredSource<'_>, +) -> Option> { + match relation { + Expression::Subquery(subquery) if matches!(subquery.this, Expression::Values(_)) => { + identifiers(&subquery.column_aliases, source) + } + Expression::Values(values) => identifiers(&values.column_aliases, source), + _ => None, + } +} + +fn projection_column(projection: &Expression, source: &AuthoredSource<'_>) -> Option { + match projection { + Expression::Alias(alias) if alias.column_aliases.is_empty() => { + source.identifier(&alias.alias) + } + Expression::Column(column) => source.identifier(&column.name), + _ => None, + } +} + +fn identifiers(identifiers: &[Identifier], source: &AuthoredSource<'_>) -> Option> { + if identifiers.is_empty() { + return None; + } + identifiers + .iter() + .map(|identifier| source.identifier(identifier)) + .collect() +} diff --git a/crates/sqlbuild-rules-native/src/compiler/_helpers/sql_tests/mod.rs b/crates/sqlbuild-rules-native/src/compiler/_helpers/sql_tests/mod.rs index 0df0e5cd40..271ed3c3ae 100644 --- a/crates/sqlbuild-rules-native/src/compiler/_helpers/sql_tests/mod.rs +++ b/crates/sqlbuild-rules-native/src/compiler/_helpers/sql_tests/mod.rs @@ -1,3 +1,4 @@ +pub(crate) mod expected_columns; pub(crate) mod extraction; pub(crate) mod planning; pub(crate) mod rendering; diff --git a/crates/sqlbuild-rules-native/src/compiler/_helpers/sql_tests/planning.rs b/crates/sqlbuild-rules-native/src/compiler/_helpers/sql_tests/planning.rs index 36c5aa97c1..174cd6fafd 100644 --- a/crates/sqlbuild-rules-native/src/compiler/_helpers/sql_tests/planning.rs +++ b/crates/sqlbuild-rules-native/src/compiler/_helpers/sql_tests/planning.rs @@ -10,8 +10,10 @@ use regex::Regex; use serde::{Deserialize, Serialize}; use serde_json::Value; +use crate::compiler::_helpers::sql_tests::expected_columns::expected_columns; use crate::compiler::_helpers::sql_tests::rendering::{ - AssertionStep, ChainStep, RenderRequest, render_comparison_sql, + AssertionStep, ChainStep, RenderRequest, render_comparison_sql, render_dialect, + rendered_chain_steps, }; use crate::constants::{TABLE_FUNCTION_TEST_MODE, UDF_TEST_MODE}; @@ -49,6 +51,23 @@ struct PlanBatchRequest { requires_derived_table_aliases: bool, #[serde(default = "default_workers")] workers: usize, + #[serde(default = "default_true")] + render_sql: bool, + #[serde(default = "default_true")] + include_plan: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ChainBatchRequest { + models: Vec, + tests: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ChainBatchResponse { + chains: Vec>, } #[derive(Debug, Deserialize)] @@ -117,10 +136,13 @@ struct CteInput { sql_body: String, } +/// One planned SQL test: the executable chain and assertion steps plus optional rendered SQL. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] struct PlanResponse { - sql: String, + sql: Option, + chain: Vec, + assertions: Vec, model_names: Vec, warnings: Vec, } @@ -157,6 +179,7 @@ struct ProjectContext { requires_derived_table_aliases: bool, analysis_templates: Arc, patterns: SqlTestPatterns, + render_dialect: Arc, } #[derive(Clone)] @@ -247,13 +270,128 @@ struct TextualChainRequest<'a> { overrides: &'a BTreeMap, fixtures: &'a TestFixtures, context: &'a ProjectContext, - resolved_chain: &'a mut HashMap, + chain: &'a mut TextualChain, +} + +/// Chain models resolved once each as named CTE bodies, so work scales with models not paths. +#[derive(Default)] +struct TextualChain { + cte_names: HashMap, + bodies: HashMap, + dependencies: HashMap>, + order: Vec, +} + +struct TextualChainInputs<'a> { + ordered_names: &'a [String], + overrides: &'a BTreeMap, + fixtures: &'a TestFixtures, + context: &'a ProjectContext, +} + +struct TextualStep { + resolved_sql: String, + lifted_ctes: Vec<(String, String)>, + body_sql: String, +} + +impl TextualChain { + fn extend(&mut self, inputs: &TextualChainInputs<'_>) -> Result, String> { + let mut reachable: HashSet = HashSet::new(); + for model_name in inputs.ordered_names { + if self.bodies.contains_key(model_name) { + continue; + } + let Some(model) = inputs.context.models.get(model_name) else { + continue; + }; + let query_sql = inputs.overrides.get(model_name).unwrap_or(&model.query_sql); + let mut chain_references: Vec = Vec::new(); + let (resolved_sql, reached) = resolve_textual_sql(TextualResolutionRequest { + query_sql, + fixtures: inputs.fixtures, + resolved_chain: &self.cte_names, + chain_references: Some(&mut chain_references), + functions: &inputs.context.functions, + patterns: &inputs.context.patterns, + })?; + reachable.extend(reached); + self.insert(model_name, resolved_sql, chain_references); + } + Ok(reachable) + } + + fn insert(&mut self, model_name: &str, body: String, dependencies: Vec) { + self.cte_names + .insert(model_name.to_string(), format!("{REF_PREFIX}{model_name}")); + self.bodies.insert(model_name.to_string(), body); + self.dependencies + .insert(model_name.to_string(), dependencies); + self.order.push(model_name.to_string()); + } + + /// Return the generated CTEs needed by `roots`, dependencies first, each exactly once. + fn closure_ctes(&self, roots: &[String], include_roots: bool) -> Vec<(String, String)> { + let mut needed: HashSet = HashSet::new(); + let mut stack: Vec = Vec::new(); + for root in roots { + if include_roots { + stack.push(root.clone()); + } else if let Some(dependencies) = self.dependencies.get(root) { + stack.extend(dependencies.iter().cloned()); + } + } + while let Some(name) = stack.pop() { + if !needed.insert(name.clone()) { + continue; + } + if let Some(dependencies) = self.dependencies.get(&name) { + stack.extend(dependencies.iter().cloned()); + } + } + self.order + .iter() + .filter(|name| needed.contains(*name)) + .filter_map(|name| { + Some(( + self.cte_names.get(name)?.clone(), + self.bodies.get(name)?.clone(), + )) + }) + .collect() + } + + fn step(&self, model_name: &str) -> Option { + let body_sql = self.bodies.get(model_name)?.clone(); + let lifted_ctes = self.closure_ctes(&[model_name.to_string()], false); + Some(TextualStep { + resolved_sql: with_generated_ctes(&lifted_ctes, &body_sql), + lifted_ctes, + body_sql, + }) + } +} + +/// Prefix generated CTEs to a query, merging into its own leading WITH clause when present. +fn with_generated_ctes(ctes: &[(String, String)], body: &str) -> String { + if ctes.is_empty() { + return body.to_string(); + } + let definitions: String = ctes + .iter() + .map(|(name, sql)| cte_definition_sql(name, sql)) + .collect::>() + .join(", "); + match leading_with_prefix_end(body) { + Some(end) => format!("{}{definitions}, {}", &body[..end], &body[end..]), + None => format!("WITH {definitions} {body}"), + } } struct AssertionResolutionRequest<'a> { assertion_sql: &'a str, fixtures: &'a TestFixtures, - resolved_chain: &'a HashMap, + chain: &'a TextualChain, functions: &'a HashMap, requires_flat_ctes: bool, patterns: &'a SqlTestPatterns, @@ -274,6 +412,7 @@ struct TextualResolutionRequest<'a> { query_sql: &'a str, fixtures: &'a TestFixtures, resolved_chain: &'a HashMap, + chain_references: Option<&'a mut Vec>, functions: &'a HashMap, patterns: &'a SqlTestPatterns, } @@ -354,9 +493,66 @@ impl GeneratedCteState { } } +/// Return each test's topologically ordered unmocked model chain without planning SQL. +pub(crate) fn resolve_chains_json(request_json: &str) -> Result { + let request: ChainBatchRequest = + serde_json::from_str(request_json).map_err(|error| error.to_string())?; + let patterns = SqlTestPatterns::new()?; + let models: HashMap = request + .models + .into_iter() + .map(|model| { + ( + model.name, + ModelInputOwned { + query_sql: model.query_sql, + model_dependencies: model.model_dependencies, + }, + ) + }) + .collect(); + let chains: Vec> = request + .tests + .into_iter() + .map(|test| match test.payload { + TestPayload::Direct { .. } => Vec::new(), + TestPayload::Model { + authored_ctes, + model_query_overrides, + expected_model_names, + assertion_ctes, + .. + } => { + let fixtures = classify_fixtures(authored_ctes, Vec::new(), assertion_ctes); + let expected_names = chain_root_names(expected_model_names, &fixtures, &patterns); + topo_sort_model_chain(TopoSortRequest { + expected_names: &expected_names, + models: &models, + overrides: &model_query_overrides, + mock_refs: &fixtures.mock_refs, + patterns: &patterns, + }) + } + }) + .collect(); + serde_json::to_string(&ChainBatchResponse { chains }).map_err(|error| error.to_string()) +} + +fn chain_root_names( + mut expected_names: Vec, + fixtures: &TestFixtures, + patterns: &SqlTestPatterns, +) -> Vec { + expected_names.extend(assertion_ref_targets(&fixtures.assertions, patterns)); + dedupe(expected_names) +} + pub(crate) fn plan_and_render_json(request_json: &str) -> Result { let request: PlanBatchRequest = serde_json::from_str(request_json).map_err(|error| error.to_string())?; + let render_sql = request.render_sql; + let include_plan = request.include_plan; + let render_dialect = Arc::new(render_dialect(request.sql_analysis_dialect.as_deref())); let context = ProjectContext { models: request .models @@ -384,6 +580,7 @@ pub(crate) fn plan_and_render_json(request_json: &str) -> Result requires_derived_table_aliases: request.requires_derived_table_aliases, analysis_templates: Arc::new(Mutex::new(HashMap::new())), patterns: SqlTestPatterns::new()?, + render_dialect, }; let workers = request.workers.clamp(1, MAX_WORKERS); let pool = rayon::ThreadPoolBuilder::new() @@ -398,11 +595,19 @@ pub(crate) fn plan_and_render_json(request_json: &str) -> Result .into_par_iter() .map(|test| { let planning_start = Instant::now(); - let planned = plan_test(test, &context)?; + let mut planned = plan_test(test, &context)?; let planning_ns = planning_start.elapsed().as_nanos(); let rendering_start = Instant::now(); + let sql = render_sql + .then(|| render_comparison_sql(&planned.request, &context.render_dialect)); + if !include_plan { + planned.request.chain.clear(); + planned.request.assertions.clear(); + } let response = PlanResponse { - sql: render_comparison_sql(planned.request), + sql, + chain: planned.request.chain, + assertions: planned.request.assertions, model_names: planned.model_names, warnings: planned.warnings, }; @@ -492,11 +697,13 @@ fn plan_direct_test( expected_cte_sql: Some(wrap_direct_sql(&plan.expected_cte.sql_body, &helper_with)), lifted_ctes: Vec::new(), comparison_body_sql: None, + expected_columns: None, }], assertions: Vec::new(), sql_analysis_enabled: context.sql_analysis_enabled, set_difference_operator: context.set_difference_operator.clone(), - _sql_analysis_dialect: Some(context.dialect.clone()), + sql_analysis_dialect: Some(context.dialect.clone()), + probe_step_index: None, }; Ok(PlannedResponse { request, @@ -510,12 +717,7 @@ fn plan_model_test( context: &ProjectContext, ) -> Result { let fixtures = classify_fixtures(plan.authored_ctes, plan.expected_ctes, plan.assertion_ctes); - let mut expected_names = plan.expected_model_names; - expected_names.extend(assertion_ref_targets( - &fixtures.assertions, - &context.patterns, - )); - let expected_names = dedupe(expected_names); + let expected_names = chain_root_names(plan.expected_model_names, &fixtures, &context.patterns); let ordered_names = topo_sort_model_chain(TopoSortRequest { expected_names: &expected_names, models: &context.models, @@ -524,9 +726,10 @@ fn plan_model_test( patterns: &context.patterns, }); let mut warnings: Vec = Vec::new(); + let mut reported_missing_mocks: HashSet<(&'static str, String)> = HashSet::new(); let mut reachable_mocks: HashSet = HashSet::new(); let mut analysis_resolved: HashMap = HashMap::new(); - let mut textual_resolved: HashMap = HashMap::new(); + let mut textual_chain: TextualChain = TextualChain::default(); let mut chain: Vec = Vec::new(); for (model_index, model_name) in ordered_names.iter().enumerate() { @@ -552,7 +755,7 @@ fn plan_model_test( &context.patterns, )?; reachable_mocks.extend(reached_table_functions); - let analyzed = if context.sql_analysis_enabled { + let analyzed = if context.sql_analysis_enabled && analysis_resolved.len() == model_index { analyze_and_resolve_sql(AnalysisResolutionRequest { query_sql: &fixture_resolved_sql, fixtures: &fixtures, @@ -576,33 +779,38 @@ fn plan_model_test( analysis_resolved.insert(model_name.clone(), analyzed); result } else { - let (resolved, reached) = ensure_textual_chain_through(TextualChainRequest { + let (step, reached) = ensure_textual_chain_through(TextualChainRequest { ordered_names: &ordered_names[..=model_index], overrides: &plan.model_query_overrides, fixtures: &fixtures, context, - resolved_chain: &mut textual_resolved, + chain: &mut textual_chain, })?; reachable_mocks.extend(reached); - (resolved, Vec::new(), None) + (step.resolved_sql, step.lifted_ctes, Some(step.body_sql)) }; - warnings.extend(unresolved_reference_warnings( - &resolved_sql, - &plan.test_name, + warnings.extend(unresolved_reference_warnings(UnresolvedReferenceRequest { + sql: &resolved_sql, + test_name: &plan.test_name, model_name, - &context.patterns, - )); + patterns: &context.patterns, + reported: &mut reported_missing_mocks, + })); + let expected_cte_sql = fixtures.expected.get(model_name).cloned(); chain.push(ChainStep { model_name: model_name.clone(), resolved_sql, - expected_cte_sql: fixtures.expected.get(model_name).cloned(), + expected_columns: expected_cte_sql + .as_deref() + .and_then(|sql| expected_columns(sql, &context.render_dialect)), + expected_cte_sql, lifted_ctes, comparison_body_sql, }); } let mut assertions: Vec = Vec::new(); - let mut textual_assertion_chain: Option> = None; + let mut textual_assertion_chain: Option = None; for (assertion_name, assertion_sql) in &fixtures.assertions { let (fixture_resolved_sql, reached_table_functions) = resolve_table_function_fixtures( assertion_sql, @@ -611,20 +819,21 @@ fn plan_model_test( &context.patterns, )?; reachable_mocks.extend(reached_table_functions); - let analyzed = if context.sql_analysis_enabled { - analyze_and_resolve_sql(AnalysisResolutionRequest { - query_sql: &fixture_resolved_sql, - fixtures: &fixtures, - resolved_chain: &analysis_resolved, - functions: &context.functions, - file_label: &plan.file_label, - dialect_name: &context.dialect, - templates: &context.analysis_templates, - patterns: &context.patterns, - })? - } else { - None - }; + let analyzed = + if context.sql_analysis_enabled && analysis_resolved.len() == ordered_names.len() { + analyze_and_resolve_sql(AnalysisResolutionRequest { + query_sql: &fixture_resolved_sql, + fixtures: &fixtures, + resolved_chain: &analysis_resolved, + functions: &context.functions, + file_label: &plan.file_label, + dialect_name: &context.dialect, + templates: &context.analysis_templates, + patterns: &context.patterns, + })? + } else { + None + }; let (resolved_sql, lifted_ctes, comparison_body_sql) = match analyzed { Some(value) if !has_unresolved_test_reference(&value.resolved_sql, &context.patterns) => @@ -647,20 +856,24 @@ fn plan_model_test( reachable_mocks.extend(reached); textual_assertion_chain = Some(chain); } - let resolved_chain = textual_assertion_chain + let chain = textual_assertion_chain .as_ref() .ok_or_else(|| planner_error("textual assertion chain is unavailable"))?; let (resolved, reached) = resolve_assertion_textual_sql(AssertionResolutionRequest { assertion_sql: &fixture_resolved_sql, fixtures: &fixtures, - resolved_chain, + chain, functions: &context.functions, requires_flat_ctes: context.requires_derived_table_aliases, patterns: &context.patterns, })?; reachable_mocks.extend(reached); - (resolved, Vec::new(), None) + ( + resolved.resolved_sql, + resolved.lifted_ctes, + Some(resolved.body_sql), + ) } }; assertions.push(AssertionStep { @@ -675,13 +888,15 @@ fn plan_model_test( &reachable_mocks, &fixtures, )); + let chain = omit_unrendered_step_sql(chain, &assertions); let model_names: Vec = chain.iter().map(|step| step.model_name.clone()).collect(); let request = RenderRequest { chain, assertions, sql_analysis_enabled: context.sql_analysis_enabled, set_difference_operator: context.set_difference_operator.clone(), - _sql_analysis_dialect: Some(context.dialect.clone()), + sql_analysis_dialect: Some(context.dialect.clone()), + probe_step_index: None, }; Ok(PlannedResponse { request, @@ -690,47 +905,47 @@ fn plan_model_test( }) } +/// Drop SQL from chain steps the renderer never emits, keeping plan output linear in chain length. +fn omit_unrendered_step_sql(chain: Vec, assertions: &[AssertionStep]) -> Vec { + let rendered_steps = rendered_chain_steps(&chain, assertions); + chain + .into_iter() + .zip(rendered_steps) + .map(|(step, rendered)| { + if rendered { + step + } else { + ChainStep { + model_name: step.model_name, + resolved_sql: String::new(), + expected_cte_sql: step.expected_cte_sql, + lifted_ctes: Vec::new(), + comparison_body_sql: None, + expected_columns: step.expected_columns, + } + } + }) + .collect() +} + fn ensure_textual_chain_through( request: TextualChainRequest<'_>, -) -> Result<(String, HashSet), String> { - let mut reachable: HashSet = HashSet::new(); - for model_name in request.ordered_names { - if request.resolved_chain.contains_key(model_name) { - continue; - } - let Some(model) = request.context.models.get(model_name) else { - continue; - }; - let query_sql = request - .overrides - .get(model_name) - .unwrap_or(&model.query_sql); - let (resolved_sql, reached) = resolve_textual_sql(TextualResolutionRequest { - query_sql, - fixtures: request.fixtures, - resolved_chain: request.resolved_chain, - functions: &request.context.functions, - patterns: &request.context.patterns, - })?; - reachable.extend(reached); - request - .resolved_chain - .insert(model_name.clone(), format!("({resolved_sql})")); - } +) -> Result<(TextualStep, HashSet), String> { + let reachable: HashSet = request.chain.extend(&TextualChainInputs { + ordered_names: request.ordered_names, + overrides: request.overrides, + fixtures: request.fixtures, + context: request.context, + })?; let current_name = request .ordered_names .last() .ok_or_else(|| "textual SQL-test chain is empty".to_string())?; - let wrapped = request - .resolved_chain - .get(current_name) + let step = request + .chain + .step(current_name) .ok_or_else(|| format!("SQL-test model '{current_name}' could not be resolved"))?; - let body = wrapped - .strip_prefix('(') - .and_then(|value| value.strip_suffix(')')) - .unwrap_or(wrapped) - .to_string(); - Ok((body, reachable)) + Ok((step, reachable)) } fn build_textual_chain( @@ -738,37 +953,26 @@ fn build_textual_chain( overrides: &BTreeMap, fixtures: &TestFixtures, context: &ProjectContext, -) -> Result<(HashMap, HashSet), String> { - let mut resolved_chain: HashMap = HashMap::new(); - let mut reachable: HashSet = HashSet::new(); - for model_name in ordered_names { - let Some(model) = context.models.get(model_name) else { - continue; - }; - let query_sql = overrides.get(model_name).unwrap_or(&model.query_sql); - let (resolved_sql, reached) = resolve_textual_sql(TextualResolutionRequest { - query_sql, - fixtures, - resolved_chain: &resolved_chain, - functions: &context.functions, - patterns: &context.patterns, - })?; - reachable.extend(reached); - resolved_chain.insert(model_name.clone(), format!("({resolved_sql})")); - } - Ok((resolved_chain, reachable)) +) -> Result<(TextualChain, HashSet), String> { + let mut chain: TextualChain = TextualChain::default(); + let reachable: HashSet = chain.extend(&TextualChainInputs { + ordered_names, + overrides, + fixtures, + context, + })?; + Ok((chain, reachable)) } fn resolve_assertion_textual_sql( request: AssertionResolutionRequest<'_>, -) -> Result<(String, HashSet), String> { +) -> Result<(TextualStep, HashSet), String> { if request.requires_flat_ctes && leading_with_prefix_end(request.assertion_sql).is_some() { return Err(planner_error( "SQL test assertion fallback cannot safely flatten an assertion beginning with WITH", )); } - let mut assertion_chain: HashMap = HashMap::new(); - let mut cte_parts: Vec = Vec::new(); + let mut referenced: Vec = Vec::new(); let mut seen: HashSet = HashSet::new(); for name in marker_names( &request.patterns.reference, @@ -778,37 +982,42 @@ fn resolve_assertion_textual_sql( if !seen.insert(name.clone()) { continue; } - let Some(resolved_sql) = request.resolved_chain.get(&name) else { + let Some(body) = request.chain.bodies.get(&name) else { continue; }; - let mut body = resolved_sql.trim(); - if body.starts_with('(') && body.ends_with(')') { - body = body[1..body.len() - 1].trim(); - } if request.requires_flat_ctes && leading_with_prefix_end(body).is_some() { return Err(planner_error( "SQL test assertion fallback cannot safely flatten a referenced model beginning with WITH", )); } - let cte_name = format!("{REF_PREFIX}{name}"); - assertion_chain.insert(name, cte_name.clone()); - cte_parts.push(cte_definition_sql(&cte_name, body)); + referenced.push(name); } - let (resolved_sql, reached) = resolve_textual_sql(TextualResolutionRequest { + let lifted_ctes: Vec<(String, String)> = request.chain.closure_ctes(&referenced, true); + let (body_sql, reached) = resolve_textual_sql(TextualResolutionRequest { query_sql: request.assertion_sql, fixtures: request.fixtures, - resolved_chain: &assertion_chain, + resolved_chain: &request.chain.cte_names, + chain_references: None, functions: request.functions, patterns: request.patterns, })?; - if cte_parts.is_empty() { - Ok((resolved_sql, reached)) + let resolved_sql = if lifted_ctes.is_empty() { + body_sql.clone() } else { - Ok(( - format!("WITH {} {resolved_sql}", cte_parts.join(", ")), - reached, - )) - } + let cte_parts: Vec = lifted_ctes + .iter() + .map(|(name, body)| cte_definition_sql(name, body)) + .collect(); + format!("WITH {} {body_sql}", cte_parts.join(", ")) + }; + Ok(( + TextualStep { + resolved_sql, + lifted_ctes, + body_sql, + }, + reached, + )) } fn classify_fixtures( @@ -1055,12 +1264,18 @@ fn resolve_textual_sql( ) -> Result<(String, HashSet), String> { let helper_with = helper_with_clause(&request.fixtures.helpers); let mut reached: HashSet = HashSet::new(); + let mut chain_references = request.chain_references; let mut result = replace_named_markers( request.query_sql, &request.patterns.reference, &request.patterns.protected, |name| { if let Some(sql) = request.resolved_chain.get(name) { + if let Some(references) = chain_references.as_deref_mut() + && !references.iter().any(|existing| existing == name) + { + references.push(name.to_string()); + } return Some(sql.clone()); } request.fixtures.mock_refs.get(name).map(|sql| { @@ -1494,7 +1709,7 @@ fn assemble_resolved_sql(cte_body_sql: &str, generated: &[(String, String)]) -> } } -fn leading_with_prefix_end(sql: &str) -> Option { +pub(crate) fn leading_with_prefix_end(sql: &str) -> Option { let mut index = skip_leading_ignorable(sql, 0); index = keyword_end(sql, index, "WITH")?; index = skip_leading_ignorable(sql, index); @@ -1540,38 +1755,52 @@ fn keyword_end(sql: &str, start: usize, keyword: &str) -> Option { Some(end) } -fn unresolved_reference_warnings( - sql: &str, - test_name: &str, - model_name: &str, - patterns: &SqlTestPatterns, -) -> Vec { +struct UnresolvedReferenceRequest<'a> { + sql: &'a str, + test_name: &'a str, + model_name: &'a str, + patterns: &'a SqlTestPatterns, + reported: &'a mut HashSet<(&'static str, String)>, +} + +/// Report each unmocked reference once per test, attributed to the first step reaching it. +fn unresolved_reference_warnings(request: UnresolvedReferenceRequest<'_>) -> Vec { + let UnresolvedReferenceRequest { + sql, + test_name, + model_name, + patterns, + reported, + } = request; if !patterns.test_reference.is_match(sql) { return Vec::new(); } let mut warnings: Vec = Vec::new(); - for name in marker_names(&patterns.reference, &patterns.protected, sql) { - warnings.push(PlanWarning { - model_name: Some(model_name.to_string()), - severity: "error", - message: format!( - "test '{test_name}': model '{model_name}' references __ref('{name}') which has no mock and is not in the expected chain" - ), - }); - } - for (pattern, function_name, suffix) in [ - (&patterns.source, SOURCE_FUNCTION, " which has no mock"), - (&patterns.seed, SEED_FUNCTION, " which has no mock"), - ] { - for name in marker_names(pattern, &patterns.protected, sql) { + let mut warn = |kind: &'static str, name: String, message: String| { + if reported.insert((kind, name)) { warnings.push(PlanWarning { model_name: Some(model_name.to_string()), severity: "error", - message: format!( - "test '{test_name}': model '{model_name}' references {function_name}('{name}'){suffix}" - ), + message, }); } + }; + for name in marker_names(&patterns.reference, &patterns.protected, sql) { + let message = format!( + "test '{test_name}': model '{model_name}' references __ref('{name}') which has no mock and is not in the expected chain" + ); + warn(REF_FUNCTION, name, message); + } + for (pattern, function_name) in [ + (&patterns.source, SOURCE_FUNCTION), + (&patterns.seed, SEED_FUNCTION), + ] { + for name in marker_names(pattern, &patterns.protected, sql) { + let message = format!( + "test '{test_name}': model '{model_name}' references {function_name}('{name}') which has no mock" + ); + warn(function_name, name, message); + } } let protected = protected_ranges(&patterns.protected, sql); for captures in patterns.dbt_reference.captures_iter(sql) { @@ -1588,13 +1817,10 @@ fn unresolved_reference_warnings( || first.to_string(), |second| format!("{first}__{}", second.as_str()), ); - warnings.push(PlanWarning { - model_name: Some(model_name.to_string()), - severity: "error", - message: format!( - "test '{test_name}': model '{model_name}' references __dbt_ref__{name} which has no mock" - ), - }); + let message = format!( + "test '{test_name}': model '{model_name}' references __dbt_ref__{name} which has no mock" + ); + warn(DBT_REF_FUNCTION, name, message); } warnings } diff --git a/crates/sqlbuild-rules-native/src/compiler/_helpers/sql_tests/rendering.rs b/crates/sqlbuild-rules-native/src/compiler/_helpers/sql_tests/rendering.rs index 156a631280..8c48343d3e 100644 --- a/crates/sqlbuild-rules-native/src/compiler/_helpers/sql_tests/rendering.rs +++ b/crates/sqlbuild-rules-native/src/compiler/_helpers/sql_tests/rendering.rs @@ -3,6 +3,8 @@ use std::collections::HashMap; use polyglot_sql::{Dialect, DialectType, Expression}; + +use crate::compiler::_helpers::sql_tests::planning::leading_with_prefix_end; use rayon::iter::{IntoParallelIterator, ParallelIterator}; use serde::{Deserialize, Serialize}; @@ -18,6 +20,29 @@ struct RenderBatchRequest { workers: usize, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DifferenceSampleRequest { + step: ChainStep, + #[serde(default = "default_true")] + sql_analysis_enabled: bool, + #[serde(default = "default_set_difference")] + set_difference_operator: String, + #[serde(default)] + sql_analysis_dialect: Option, + direction: DifferenceDirection, + sample_limit: usize, + #[serde(default)] + use_top_clause: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +enum DifferenceDirection { + Unexpected, + Missing, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct RenderRequest { @@ -29,11 +54,13 @@ pub(crate) struct RenderRequest { pub(crate) sql_analysis_enabled: bool, #[serde(default = "default_set_difference")] pub(crate) set_difference_operator: String, - #[serde(default, rename = "sqlAnalysisDialect")] - pub(crate) _sql_analysis_dialect: Option, + #[serde(default)] + pub(crate) sql_analysis_dialect: Option, + #[serde(default)] + pub(crate) probe_step_index: Option, } -#[derive(Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct ChainStep { pub(crate) model_name: String, @@ -44,9 +71,11 @@ pub(crate) struct ChainStep { pub(crate) lifted_ctes: Vec<(String, String)>, #[serde(default)] pub(crate) comparison_body_sql: Option, + #[serde(default)] + pub(crate) expected_columns: Option>, } -#[derive(Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct AssertionStep { pub(crate) name: String, @@ -63,18 +92,26 @@ struct RenderResponse { sql: String, } -#[derive(Default)] -struct RenderCteState { +struct RenderCteState<'a> { + dialect: &'a Dialect, lifted: Vec<(String, String)>, - name_counts: HashMap, + name_counts: CteSuffixCounter, } -impl RenderCteState { +impl<'a> RenderCteState<'a> { + fn new(dialect: &'a Dialect) -> Self { + Self { + dialect, + lifted: Vec::new(), + name_counts: CteSuffixCounter::default(), + } + } + fn lift(&mut self, sql: &str, enabled: bool) -> String { - if !enabled { + if !enabled || leading_with_prefix_end(sql).is_none() { return sql.to_string(); } - let Some((step_ctes, body_sql)) = split_top_level_with(sql) else { + let Some((step_ctes, body_sql)) = split_top_level_with(sql, self.dialect) else { return sql.to_string(); }; if step_ctes.iter().any(|(name, body)| { @@ -88,6 +125,22 @@ impl RenderCteState { body_sql } + /// Place a chain step's generated CTEs at top level and return its comparison body. + fn actual_step_sql(&mut self, step: &ChainStep, enabled: bool) -> String { + if step.lifted_ctes.is_empty() { + return self.lift(&step.resolved_sql, enabled); + } + if !self.merge(&step.lifted_ctes) { + return step.resolved_sql.clone(); + } + self.lift( + step.comparison_body_sql + .as_deref() + .unwrap_or(&step.resolved_sql), + enabled, + ) + } + fn merge(&mut self, ctes: &[(String, String)]) -> bool { if ctes.iter().any(|(name, body)| { existing_cte(&self.lifted, name).is_some_and(|(_, existing)| existing != body) @@ -103,8 +156,20 @@ impl RenderCteState { } fn unique_suffix(&mut self, model_name: &str) -> String { + self.name_counts.next(model_name) + } +} + +/// Readable, collision-free comparison CTE suffixes in step order. +#[derive(Default)] +struct CteSuffixCounter { + counts: HashMap, +} + +impl CteSuffixCounter { + fn next(&mut self, model_name: &str) -> String { let base = sanitize_cte_suffix(model_name); - let count = self.name_counts.entry(base.clone()).or_default(); + let count = self.counts.entry(base.clone()).or_default(); *count += 1; if *count == 1 { base @@ -114,6 +179,18 @@ impl RenderCteState { } } +/// Mark the chain steps whose actual SQL the comparison renderer emits. +pub(crate) fn rendered_chain_steps(chain: &[ChainStep], assertions: &[AssertionStep]) -> Vec { + let mut suffixes = CteSuffixCounter::default(); + chain + .iter() + .map(|step| { + let actual_cte = format!("__actual__{}", suffixes.next(&step.model_name)); + step.expected_cte_sql.is_some() || assertions_use_actual(assertions, &actual_cte) + }) + .collect() +} + pub(crate) fn render_json(request_json: &str) -> Result { let request: RenderBatchRequest = serde_json::from_str(request_json).map_err(|error| error.to_string())?; @@ -123,61 +200,124 @@ pub(crate) fn render_json(request_json: &str) -> Result { .stack_size(WORKER_STACK_BYTES) .build() .map_err(|error| error.to_string())?; + let dialects: HashMap, Dialect> = request + .requests + .iter() + .map(|item| item.sql_analysis_dialect.clone()) + .collect::>() + .into_iter() + .map(|name| { + let dialect = render_dialect(name.as_deref()); + (name, dialect) + }) + .collect(); let responses: Vec = pool.install(|| { request .requests .into_par_iter() .map(|request| RenderResponse { - sql: render_comparison_sql(request), + sql: render_comparison_sql(&request, &dialects[&request.sql_analysis_dialect]), }) .collect() }); serde_json::to_string(&responses).map_err(|error| error.to_string()) } -pub(crate) fn render_comparison_sql(request: RenderRequest) -> String { +/// Resolve the SQL-analysis dialect used to split and regenerate top-level CTEs. +pub(crate) fn render_dialect(name: Option<&str>) -> Dialect { + name.and_then(Dialect::get_by_name) + .unwrap_or_else(|| Dialect::get(DialectType::Generic)) +} + +pub(crate) fn render_difference_sample_json(request_json: &str) -> Result { + let request: DifferenceSampleRequest = + serde_json::from_str(request_json).map_err(|error| error.to_string())?; + let dialect = render_dialect(request.sql_analysis_dialect.as_deref()); + serde_json::to_string(&RenderResponse { + sql: render_difference_sample_sql(&request, &dialect), + }) + .map_err(|error| error.to_string()) +} + +fn render_difference_sample_sql(request: &DifferenceSampleRequest, dialect: &Dialect) -> String { + let step = &request.step; + let Some(expected_input) = step.expected_cte_sql.as_deref() else { + return String::new(); + }; + let mut cte_state = RenderCteState::new(dialect); + let actual_sql = cte_state.actual_step_sql(step, request.sql_analysis_enabled); + let expected_sql = cte_state.lift(expected_input, request.sql_analysis_enabled); + let mut cte_parts: Vec = cte_state + .lifted + .iter() + .map(|(name, sql)| cte_definition_sql(name, sql)) + .collect(); + cte_parts.push(cte_definition_sql("__actual", &actual_sql)); + cte_parts.push(cte_definition_sql("__expected", &expected_sql)); + let (left, right) = match request.direction { + DifferenceDirection::Unexpected => ("__actual", "__expected"), + DifferenceDirection::Missing => ("__expected", "__actual"), + }; + let (bounded_select, limit_clause) = if request.use_top_clause { + ( + format!("SELECT TOP {} *", request.sample_limit), + String::new(), + ) + } else { + ( + "SELECT *".to_string(), + format!(" LIMIT {}", request.sample_limit), + ) + }; + let projection = compared_projection(step); + format!( + "WITH {}\n{bounded_select} FROM (SELECT {projection} FROM {left} {} SELECT {projection} FROM {right}) AS __sqlbuild_difference{limit_clause}", + cte_parts.join(",\n"), + request.set_difference_operator, + ) +} + +/// Columns compared for an expected-output step: the listed expected columns, else every column. +fn compared_projection(step: &ChainStep) -> String { + step.expected_columns + .as_ref() + .map_or_else(|| "*".to_string(), |columns| columns.join(", ")) +} + +pub(crate) fn render_comparison_sql(request: &RenderRequest, dialect: &Dialect) -> String { if request.chain.is_empty() && request.assertions.is_empty() { return String::new(); } - let mut cte_state = RenderCteState::default(); + let mut cte_state = RenderCteState::new(dialect); let mut comparison_ctes: Vec = Vec::new(); let mut select_parts: Vec = Vec::new(); + let rendered_steps = rendered_chain_steps(&request.chain, &request.assertions); + let mut probe_actual_cte: Option = None; for (step_index, step) in request.chain.iter().enumerate() { let suffix = cte_state.unique_suffix(&step.model_name); let actual_cte = format!("__actual__{suffix}"); let expected_cte = format!("__expected__{suffix}"); - if step.expected_cte_sql.is_none() - && !assertions_use_actual(&request.assertions, &actual_cte) - { + if !rendered_steps[step_index] { continue; } - let actual_sql = if step.lifted_ctes.is_empty() { - cte_state.lift(&step.resolved_sql, request.sql_analysis_enabled) - } else { - if cte_state.merge(&step.lifted_ctes) { - cte_state.lift( - step.comparison_body_sql - .as_deref() - .unwrap_or(&step.resolved_sql), - request.sql_analysis_enabled, - ) - } else { - step.resolved_sql.clone() - } - }; + let actual_sql = cte_state.actual_step_sql(step, request.sql_analysis_enabled); comparison_ctes.push(cte_definition_sql(&actual_cte, &actual_sql)); + if request.probe_step_index == Some(step_index) { + probe_actual_cte = Some(actual_cte.clone()); + } let Some(expected_input) = step.expected_cte_sql.as_deref() else { continue; }; let expected_sql = cte_state.lift(expected_input, request.sql_analysis_enabled); comparison_ctes.push(cte_definition_sql(&expected_cte, &expected_sql)); + let projection = compared_projection(step); select_parts.push(format!( "SELECT {step_index} AS step_index, '{}' AS model_name, \ (SELECT COUNT(*) FROM {actual_cte}) AS actual_count, \ (SELECT COUNT(*) FROM {expected_cte}) AS expected_count, \ - (SELECT COUNT(*) FROM (SELECT * FROM {actual_cte} {} SELECT * FROM {expected_cte}) AS __sqlbuild_mismatch) AS unexpected_count, \ - (SELECT COUNT(*) FROM (SELECT * FROM {expected_cte} {} SELECT * FROM {actual_cte}) AS __sqlbuild_missing) AS missing_count", + (SELECT COUNT(*) FROM (SELECT {projection} FROM {actual_cte} {} SELECT {projection} FROM {expected_cte}) AS __sqlbuild_mismatch) AS unexpected_count, \ + (SELECT COUNT(*) FROM (SELECT {projection} FROM {expected_cte} {} SELECT {projection} FROM {actual_cte}) AS __sqlbuild_missing) AS missing_count", escape_sql_string(&step.model_name), request.set_difference_operator, request.set_difference_operator, @@ -224,6 +364,14 @@ pub(crate) fn render_comparison_sql(request: RenderRequest) -> String { .map(|(name, sql)| cte_definition_sql(name, sql)) .collect(); cte_parts.extend(comparison_ctes); + if request.probe_step_index.is_some() { + return probe_actual_cte.map_or_else(String::new, |actual_cte| { + format!( + "WITH {}\nSELECT * FROM {actual_cte} WHERE 1 = 0", + cte_parts.join(",\n") + ) + }); + } format!( "WITH {}\n{}", cte_parts.join(",\n"), @@ -237,9 +385,8 @@ fn existing_cte<'a>(lifted: &'a [(String, String)], name: &str) -> Option<&'a (S .find(|(existing, _)| existing.eq_ignore_ascii_case(name)) } -fn split_top_level_with(sql: &str) -> Option<(Vec<(String, String)>, String)> { +fn split_top_level_with(sql: &str, dialect: &Dialect) -> Option<(Vec<(String, String)>, String)> { let (protected, identifiers) = protect_backtick_identifiers(sql); - let dialect = Dialect::get(DialectType::Generic); let mut statements = match dialect.parse(&protected) { Ok(statements) => statements, Err(_) => return None, diff --git a/crates/sqlbuild-rules-native/src/compiler/main/mod.rs b/crates/sqlbuild-rules-native/src/compiler/main/mod.rs index a38f94aff9..8e6cc4ba3a 100644 --- a/crates/sqlbuild-rules-native/src/compiler/main/mod.rs +++ b/crates/sqlbuild-rules-native/src/compiler/main/mod.rs @@ -2,6 +2,8 @@ pub(crate) mod model_header_parsing; pub(crate) mod model_header_tokenizing; pub(crate) mod sql_interpolation; pub(crate) mod sql_references; +pub(crate) mod sql_test_chain_resolution; +pub(crate) mod sql_test_difference_sampling; pub(crate) mod sql_test_extraction; pub(crate) mod sql_test_planning; pub(crate) mod sql_test_rendering; diff --git a/crates/sqlbuild-rules-native/src/compiler/main/sql_test_chain_resolution.rs b/crates/sqlbuild-rules-native/src/compiler/main/sql_test_chain_resolution.rs new file mode 100644 index 0000000000..4e43c334a8 --- /dev/null +++ b/crates/sqlbuild-rules-native/src/compiler/main/sql_test_chain_resolution.rs @@ -0,0 +1,5 @@ +use crate::compiler::_helpers; + +pub(crate) fn resolve_chains_json(request_json: &str) -> Result { + _helpers::sql_tests::planning::resolve_chains_json(request_json) +} diff --git a/crates/sqlbuild-rules-native/src/compiler/main/sql_test_difference_sampling.rs b/crates/sqlbuild-rules-native/src/compiler/main/sql_test_difference_sampling.rs new file mode 100644 index 0000000000..a3fa1a344d --- /dev/null +++ b/crates/sqlbuild-rules-native/src/compiler/main/sql_test_difference_sampling.rs @@ -0,0 +1,5 @@ +use crate::compiler::_helpers; + +pub(crate) fn render_difference_sample_json(request_json: &str) -> Result { + _helpers::sql_tests::rendering::render_difference_sample_json(request_json) +} diff --git a/crates/sqlbuild-rules-native/src/compiler/tests.rs b/crates/sqlbuild-rules-native/src/compiler/tests.rs index 0f31c82325..6d65a61e11 100644 --- a/crates/sqlbuild-rules-native/src/compiler/tests.rs +++ b/crates/sqlbuild-rules-native/src/compiler/tests.rs @@ -6,6 +6,8 @@ mod model_header_tokenization; mod sql_interpolation; #[path = "tests/test_sql_references.rs"] mod sql_references; +#[path = "tests/test_sql_test_expected_columns.rs"] +mod sql_test_expected_columns; #[path = "tests/test_sql_test_extraction.rs"] mod sql_test_extraction; #[path = "tests/test_sql_test_planning.rs"] diff --git a/crates/sqlbuild-rules-native/src/compiler/tests/helpers.rs b/crates/sqlbuild-rules-native/src/compiler/tests/helpers.rs index 840b7174f2..278b84de7f 100644 --- a/crates/sqlbuild-rules-native/src/compiler/tests/helpers.rs +++ b/crates/sqlbuild-rules-native/src/compiler/tests/helpers.rs @@ -408,3 +408,606 @@ pub(crate) fn ordered_comparison_batch_preserves_order() -> bool { assert!(sql.contains("EXCEPT")); true } + +pub(crate) fn shared_textual_chain_renders_each_model_once() -> bool { + const LAYERS: usize = 16; + let mut models: Vec = vec![json!({ + "name": "orders_00", + "querySql": "SELECT order_id, amount FROM __source(\"raw_orders\")", + "modelDependencies": [] + })]; + for side in ["left", "right"] { + models.push(diamond_model(1, side, "orders_00", "orders_00")); + } + for layer in 2..=LAYERS { + let previous: String = format!("orders_{:02}_left", layer - 1); + let other: String = format!("orders_{:02}_right", layer - 1); + for side in ["left", "right"] { + models.push(diamond_model(layer, side, &previous, &other)); + } + } + let top: String = format!("orders_{LAYERS:02}_left"); + let response: Value = serde_json::from_str( + &crate::compiler::main::sql_test_planning::plan_and_render_json( + &json!({ + "models": models, + "functions": [], + "tests": [{ + "name": "orders_totals", + "fileLabel": "tests/orders_totals.sql", + "payload": { + "kind": "model", + "authoredCtes": [{ + "name": "__source__raw_orders", + "sqlBody": "SELECT 1 AS order_id, 1 AS amount" + }], + "expectedCtes": [{ + "name": format!("__expected__{top}"), + "sqlBody": "SELECT 1 AS order_id, 65536 AS amount" + }], + "expectedModelNames": [top], + "assertionCtes": [] + } + }], + "sqlAnalysisEnabled": false, + "sqlAnalysisDialect": "duckdb", + "setDifferenceOperator": "EXCEPT", + "workers": 1 + }) + .to_string(), + ) + .expect("test assumption must hold"), + ) + .expect("test assumption must hold"); + + let sql = response["artifacts"][0]["sql"] + .as_str() + .expect("test assumption must hold"); + assert!(sql.len() < 50_000, "rendered {} bytes", sql.len()); + for layer in 1..LAYERS { + for side in ["left", "right"] { + let definition = format!("__ref__orders_{layer:02}_{side} AS ("); + assert_eq!(sql.matches(&definition).count(), 1, "{definition}"); + } + } + assert_eq!(sql.matches("__ref__orders_00 AS (").count(), 1); + true +} + +fn diamond_model(layer: usize, side: &str, previous: &str, other: &str) -> Value { + json!({ + "name": format!("orders_{layer:02}_{side}"), + "querySql": format!( + "SELECT a.order_id, a.amount + b.amount AS amount \ + FROM __ref(\"{previous}\") AS a \ + INNER JOIN __ref(\"{other}\") AS b ON a.order_id = b.order_id" + ), + "modelDependencies": [previous, other] + }) +} + +fn deep_diamond_models(layers: usize) -> Vec { + let mut models: Vec = vec![json!({ + "name": "orders_00", + "querySql": "SELECT o.order_id, o.amount FROM __source(\"raw_orders\") AS o \ + INNER JOIN __source(\"raw_customers\") AS c ON o.customer_id = c.customer_id", + "modelDependencies": [] + })]; + for side in ["left", "right"] { + models.push(diamond_model(1, side, "orders_00", "orders_00")); + } + for layer in 2..=layers { + let previous: String = format!("orders_{:02}_left", layer - 1); + let other: String = format!("orders_{:02}_right", layer - 1); + for side in ["left", "right"] { + models.push(diamond_model(layer, side, &previous, &other)); + } + } + models +} + +fn plan_deep_diamond_with_missing_mock(sql_analysis_enabled: bool) -> Value { + const LAYERS: usize = 12; + let top: String = format!("orders_{LAYERS:02}_left"); + serde_json::from_str( + &crate::compiler::main::sql_test_planning::plan_and_render_json( + &json!({ + "models": deep_diamond_models(LAYERS), + "tests": [{ + "name": "orders_totals", + "fileLabel": "tests/orders_totals.sql", + "payload": { + "kind": "model", + "authoredCtes": [{ + "name": "__source__raw_customers", + "sqlBody": "SELECT 1 AS customer_id" + }], + "expectedCtes": [{ + "name": format!("__expected__{top}"), + "sqlBody": "SELECT 1 AS order_id, 1 AS amount" + }], + "expectedModelNames": [top], + } + }], + "sqlAnalysisEnabled": sql_analysis_enabled, + "sqlAnalysisDialect": "duckdb", + "renderSql": false + }) + .to_string(), + ) + .expect("test assumption must hold"), + ) + .expect("test assumption must hold") +} + +pub(crate) fn deep_shared_graph_reports_missing_mock_once() -> bool { + for sql_analysis_enabled in [true, false] { + let response = plan_deep_diamond_with_missing_mock(sql_analysis_enabled); + assert_eq!( + response["artifacts"][0]["warnings"], + json!([{ + "modelName": "orders_00", + "severity": "error", + "message": "test 'orders_totals': model 'orders_00' references __source('raw_orders') which has no mock" + }]), + "sql_analysis_enabled={sql_analysis_enabled}" + ); + } + true +} + +pub(crate) fn plan_without_rendering_returns_executable_steps() -> bool { + let response: Value = serde_json::from_str( + &crate::compiler::main::sql_test_planning::plan_and_render_json( + &json!({ + "models": [ + { + "name": "stg_orders", + "querySql": "SELECT * FROM __source(\"raw_orders\")", + "modelDependencies": [] + }, + { + "name": "orders", + "querySql": "SELECT * FROM __ref(\"stg_orders\")", + "modelDependencies": ["stg_orders"] + } + ], + "tests": [{ + "name": "orders_case", + "fileLabel": "tests/orders.sql", + "payload": { + "kind": "model", + "authoredCtes": [{ + "name": "__source__raw_orders", + "sqlBody": "SELECT 1 AS order_id" + }], + "expectedCtes": [{ + "name": "__expected__orders", + "sqlBody": "SELECT 1 AS order_id" + }], + "expectedModelNames": ["orders"], + "assertionCtes": [{ + "name": "__assert__positive", + "sqlBody": "SELECT * FROM __ref(\"orders\") WHERE order_id < 0" + }] + } + }], + "sqlAnalysisEnabled": false, + "sqlAnalysisDialect": "duckdb", + "renderSql": false + }) + .to_string(), + ) + .expect("test assumption must hold"), + ) + .expect("test assumption must hold"); + + let artifact = &response["artifacts"][0]; + assert_eq!(artifact["sql"], Value::Null); + assert_eq!(artifact["chain"][0]["modelName"], json!("stg_orders")); + assert_eq!(artifact["chain"][0]["expectedCteSql"], Value::Null); + assert_eq!( + artifact["chain"][1]["liftedCtes"], + json!([["__ref__stg_orders", "SELECT * FROM (SELECT 1 AS order_id)"]]) + ); + assert_eq!( + artifact["chain"][1]["comparisonBodySql"], + json!("SELECT * FROM __ref__stg_orders") + ); + assert_eq!( + artifact["chain"][1]["expectedCteSql"], + json!("SELECT 1 AS order_id") + ); + assert_eq!(artifact["assertions"][0]["name"], json!("positive")); + assert_eq!( + artifact["assertions"][0]["comparisonBodySql"], + json!("SELECT * FROM __ref__orders WHERE order_id < 0") + ); + assert_eq!( + artifact["assertions"][0]["liftedCtes"][1], + json!(["__ref__orders", "SELECT * FROM __ref__stg_orders"]) + ); + assert!( + artifact["assertions"][0]["resolvedSql"] + .as_str() + .is_some_and(|sql| sql.contains("__ref__orders AS (SELECT * FROM __ref__stg_orders)")) + ); + true +} + +pub(crate) fn upstream_fallback_resolves() -> bool { + let request = json!({ + "models": [ + {"name": "stg_orders", "querySql": "SELECT __udf(\"identity_value\")(order_id) AS order_id FROM __source(\"raw_orders\")"}, + {"name": "orders", "querySql": "SELECT * FROM __ref(\"stg_orders\")", "modelDependencies": ["stg_orders"]} + ], + "functions": [{"name": "identity_value", "udfPrefix": "identity_value(", "udfSuffix": ")"}], + "tests": [{ + "name": "orders_case", "fileLabel": "tests/orders.sql", + "payload": { + "kind": "model", + "authoredCtes": [{"name": "__source__raw_orders", "sqlBody": "SELECT 1 AS order_id"}], + "expectedCtes": [{"name": "__expected__orders", "sqlBody": "SELECT 1 AS order_id"}], + "expectedModelNames": ["orders"], + "assertionCtes": [{"name": "__assert__positive", "sqlBody": "SELECT * FROM __ref(\"orders\") WHERE order_id < 0"}] + } + }], + "sqlAnalysisEnabled": true, "sqlAnalysisDialect": "duckdb" + }); + let response: Value = serde_json::from_str( + &crate::compiler::main::sql_test_planning::plan_and_render_json(&request.to_string()) + .expect("planning succeeds"), + ) + .expect("valid JSON"); + let artifact = &response["artifacts"][0]; + assert_eq!(artifact["warnings"], json!([])); + let sql = artifact["sql"].as_str().expect("rendered SQL"); + assert!(!sql.contains("__ref(\"")); + assert!(sql.contains("identity_value((order_id))"), "{sql}"); + assert!(sql.contains("__ref__stg_orders")); + let mut artifact_request = request; + artifact_request["includePlan"] = json!(false); + let compact: Value = serde_json::from_str( + &crate::compiler::main::sql_test_planning::plan_and_render_json( + &artifact_request.to_string(), + ) + .expect("artifact planning succeeds"), + ) + .expect("valid artifact JSON"); + assert_eq!(compact["artifacts"][0]["sql"], artifact["sql"]); + assert_eq!( + compact["artifacts"][0]["modelNames"], + artifact["modelNames"] + ); + assert_eq!(compact["artifacts"][0]["warnings"], artifact["warnings"]); + assert_eq!(compact["artifacts"][0]["chain"], json!([])); + assert_eq!(compact["artifacts"][0]["assertions"], json!([])); + true +} + +pub(crate) fn chain_resolution_orders_unmocked_models() -> bool { + let response: Value = serde_json::from_str( + &crate::compiler::main::sql_test_chain_resolution::resolve_chains_json( + &json!({ + "models": deep_diamond_models(2), + "tests": [ + { + "name": "orders_totals", + "fileLabel": "tests/orders_totals.sql", + "payload": { + "kind": "model", + "authoredCtes": [{ + "name": "__ref__orders_01_right", + "sqlBody": "SELECT 1 AS order_id, 1 AS amount" + }], + "expectedModelNames": ["orders_02_left"] + } + }, + { + "name": "direct_case", + "fileLabel": "tests/direct.sql", + "payload": { + "kind": "direct", + "mode": "macro", + "actualCte": {"name": "__macro_actual__", "sqlBody": "SELECT 1"}, + "expectedCte": {"name": "__macro_expected__", "sqlBody": "SELECT 1"} + } + } + ] + }) + .to_string(), + ) + .expect("test assumption must hold"), + ) + .expect("test assumption must hold"); + + assert_eq!( + response["chains"], + json!([["orders_00", "orders_01_left", "orders_02_left"], []]) + ); + true +} + +pub(crate) fn difference_sample_lifts_generated_ctes_and_bounds_rows() -> bool { + let request = |use_top_clause: bool| { + json!({ + "step": { + "modelName": "orders", + "resolvedSql": "WITH __ref__stg_orders AS (SELECT 1 AS order_id) SELECT * FROM __ref__stg_orders", + "expectedCteSql": "SELECT 2 AS order_id", + "liftedCtes": [["__ref__stg_orders", "SELECT 1 AS order_id"]], + "comparisonBodySql": "SELECT * FROM __ref__stg_orders" + }, + "sqlAnalysisEnabled": false, + "setDifferenceOperator": "EXCEPT", + "sqlAnalysisDialect": "duckdb", + "direction": "missing", + "sampleLimit": 3, + "useTopClause": use_top_clause + }) + .to_string() + }; + let limited: Value = serde_json::from_str( + &crate::compiler::main::sql_test_difference_sampling::render_difference_sample_json( + &request(false), + ) + .expect("test assumption must hold"), + ) + .expect("test assumption must hold"); + let top: Value = serde_json::from_str( + &crate::compiler::main::sql_test_difference_sampling::render_difference_sample_json( + &request(true), + ) + .expect("test assumption must hold"), + ) + .expect("test assumption must hold"); + + assert_eq!( + limited["sql"], + json!( + "WITH __ref__stg_orders AS (SELECT 1 AS order_id),\n\ + __actual AS (SELECT * FROM __ref__stg_orders),\n\ + __expected AS (SELECT 2 AS order_id)\n\ + SELECT * FROM (SELECT * FROM __expected EXCEPT SELECT * FROM __actual) \ + AS __sqlbuild_difference LIMIT 3" + ) + ); + assert!( + top["sql"] + .as_str() + .is_some_and(|sql| sql.contains("SELECT TOP 3 * FROM (") && !sql.contains("LIMIT")) + ); + true +} + +fn step_sql_bytes(step: &Value) -> usize { + let text_len = |value: &Value| value.as_str().map_or(0, str::len); + let lifted: usize = step["liftedCtes"].as_array().map_or(0, |ctes| { + ctes.iter() + .map(|cte| text_len(&cte[0]) + text_len(&cte[1])) + .sum() + }); + text_len(&step["resolvedSql"]) + text_len(&step["comparisonBodySql"]) + lifted +} + +pub(crate) fn long_chain_plan_output_stays_linear() -> bool { + const MODELS: usize = 200; + let mut models: Vec = vec![json!({ + "name": "orders_000", + "querySql": "SELECT order_id, amount FROM __source(\"raw_orders\")", + "modelDependencies": [] + })]; + for index in 1..MODELS { + let previous: String = format!("orders_{:03}", index - 1); + models.push(json!({ + "name": format!("orders_{index:03}"), + "querySql": format!( + "SELECT order_id, amount + 1 AS amount FROM __ref(\"{previous}\")" + ), + "modelDependencies": [previous] + })); + } + let tip: String = format!("orders_{:03}", MODELS - 1); + for sql_analysis_enabled in [true, false] { + let response: Value = serde_json::from_str( + &crate::compiler::main::sql_test_planning::plan_and_render_json( + &json!({ + "models": models, + "tests": [{ + "name": "orders_tip", + "fileLabel": "tests/orders_tip.sql", + "payload": { + "kind": "model", + "authoredCtes": [{ + "name": "__source__raw_orders", + "sqlBody": "SELECT 1 AS order_id, 0 AS amount" + }], + "expectedCtes": [{ + "name": format!("__expected__{tip}"), + "sqlBody": "SELECT 1 AS order_id, 199 AS amount" + }], + "expectedModelNames": [tip], + } + }], + "sqlAnalysisEnabled": sql_analysis_enabled, + "sqlAnalysisDialect": "duckdb" + }) + .to_string(), + ) + .expect("test assumption must hold"), + ) + .expect("test assumption must hold"); + let artifact = &response["artifacts"][0]; + let sql = artifact["sql"].as_str().expect("test assumption must hold"); + let chain = artifact["chain"] + .as_array() + .expect("test assumption must hold"); + assert_eq!(chain.len(), MODELS); + let step_bytes: usize = chain.iter().map(step_sql_bytes).sum(); + assert!( + step_bytes <= 3 * sql.len(), + "step SQL {step_bytes} bytes for {} rendered bytes", + sql.len() + ); + assert!( + chain[..MODELS - 1] + .iter() + .all(|step| step_sql_bytes(step) == 0) + ); + + let mut padded_chain: Vec = chain.clone(); + for step in &mut padded_chain[..MODELS - 1] { + step["resolvedSql"] = json!("SELECT unrendered_column FROM unrendered_relation"); + step["liftedCtes"] = json!([["unrendered_cte", "SELECT 1"]]); + step["comparisonBodySql"] = json!("SELECT unrendered_column"); + } + let rendered: Value = serde_json::from_str( + &crate::compiler::main::sql_test_rendering::render_json( + &json!({ + "requests": [{ + "chain": padded_chain, + "assertions": [], + "sqlAnalysisEnabled": sql_analysis_enabled, + "setDifferenceOperator": "EXCEPT", + "sqlAnalysisDialect": "duckdb" + }] + }) + .to_string(), + ) + .expect("test assumption must hold"), + ) + .expect("test assumption must hold"); + assert_eq!(rendered[0]["sql"].as_str(), Some(sql)); + } + true +} + +fn render_single(request: Value) -> String { + let response: Value = serde_json::from_str( + &crate::compiler::main::sql_test_rendering::render_json( + &json!({"requests": [request]}).to_string(), + ) + .expect("test assumption must hold"), + ) + .expect("test assumption must hold"); + response[0]["sql"] + .as_str() + .expect("test assumption must hold") + .to_string() +} + +fn partial_expected_step() -> Value { + json!({ + "modelName": "orders", + "resolvedSql": "SELECT 1 AS order_id, 'paid' AS status, 10 AS amount", + "expectedCteSql": "SELECT 'paid' AS status, 1 AS order_id", + "expectedColumns": ["status", "order_id"] + }) +} + +pub(crate) fn partial_expected_columns_project_both_sides() -> bool { + let sql = render_single(json!({ + "chain": [partial_expected_step()], + "sqlAnalysisEnabled": false, + "sqlAnalysisDialect": "duckdb" + })); + assert!(sql.contains("(SELECT COUNT(*) FROM __actual__orders) AS actual_count")); + assert!(sql.contains( + "(SELECT status, order_id FROM __actual__orders EXCEPT SELECT status, order_id FROM __expected__orders) AS __sqlbuild_mismatch" + ), "{sql}"); + assert!(sql.contains( + "(SELECT status, order_id FROM __expected__orders EXCEPT SELECT status, order_id FROM __actual__orders) AS __sqlbuild_missing" + ), "{sql}"); + assert!(!sql.contains("SELECT * FROM __actual__orders")); + true +} + +pub(crate) fn actual_probe_selects_zero_rows_from_step() -> bool { + let sql = render_single(json!({ + "chain": [partial_expected_step()], + "sqlAnalysisEnabled": false, + "sqlAnalysisDialect": "duckdb", + "probeStepIndex": 0 + })); + assert!(sql.starts_with("WITH __actual__orders AS ("), "{sql}"); + assert!( + sql.ends_with("\nSELECT * FROM __actual__orders WHERE 1 = 0"), + "{sql}" + ); + assert!(!sql.contains("UNION ALL")); + true +} + +pub(crate) fn sqlserver_difference_sample_projects_bracketed_columns() -> bool { + let response: Value = serde_json::from_str( + &crate::compiler::main::sql_test_difference_sampling::render_difference_sample_json( + &json!({ + "step": { + "modelName": "orders", + "resolvedSql": "SELECT 1 AS [Order Id], 10 AS amount, 'paid' AS status", + "expectedCteSql": "SELECT 1 AS [Order Id], 10 AS amount", + "expectedColumns": ["[Order Id]", "amount"] + }, + "sqlAnalysisEnabled": true, + "setDifferenceOperator": "EXCEPT", + "sqlAnalysisDialect": "tsql", + "direction": "unexpected", + "sampleLimit": 3, + "useTopClause": true + }) + .to_string(), + ) + .expect("test assumption must hold"), + ) + .expect("test assumption must hold"); + let sql = response["sql"].as_str().expect("test assumption must hold"); + assert!(sql.contains( + "SELECT TOP 3 * FROM (SELECT [Order Id], amount FROM __actual EXCEPT SELECT [Order Id], amount FROM __expected) AS __sqlbuild_difference" + ), "{sql}"); + true +} + +pub(crate) fn snowflake_plan_keeps_quoted_expected_columns() -> bool { + let response: Value = serde_json::from_str( + &crate::compiler::main::sql_test_planning::plan_and_render_json( + &json!({ + "models": [{ + "name": "orders", + "querySql": "SELECT order_id AS \"Order Id\", status, amount FROM __source(\"raw_orders\")", + "modelDependencies": [] + }], + "tests": [{ + "name": "orders_case", + "fileLabel": "tests/orders.sql", + "payload": { + "kind": "model", + "authoredCtes": [{ + "name": "__source__raw_orders", + "sqlBody": "SELECT 1 AS order_id, 'paid' AS status, 10 AS amount" + }], + "expectedCtes": [{ + "name": "__expected__orders", + "sqlBody": "SELECT 'paid' AS status, 1 AS \"Order Id\"" + }], + "expectedModelNames": ["orders"] + } + }], + "sqlAnalysisEnabled": false, + "sqlAnalysisDialect": "snowflake" + }) + .to_string(), + ) + .expect("test assumption must hold"), + ) + .expect("test assumption must hold"); + let artifact = &response["artifacts"][0]; + assert_eq!( + artifact["chain"][0]["expectedColumns"], + json!(["status", "\"Order Id\""]) + ); + let sql = artifact["sql"].as_str().expect("test assumption must hold"); + assert!(sql.contains( + "SELECT status, \"Order Id\" FROM __actual__orders EXCEPT SELECT status, \"Order Id\" FROM __expected__orders" + ), "{sql}"); + true +} diff --git a/crates/sqlbuild-rules-native/src/compiler/tests/test_sql_test_expected_columns.rs b/crates/sqlbuild-rules-native/src/compiler/tests/test_sql_test_expected_columns.rs new file mode 100644 index 0000000000..4e1752ed63 --- /dev/null +++ b/crates/sqlbuild-rules-native/src/compiler/tests/test_sql_test_expected_columns.rs @@ -0,0 +1,95 @@ +use crate::compiler::_helpers::sql_tests::expected_columns::expected_columns; +use crate::compiler::_helpers::sql_tests::rendering::render_dialect; +use crate::compiler::tests::test_types::ExpectedColumnsTestCase; + +#[test] +fn given_expected_cte_bodies_when_reading_columns_then_authored_names_or_none_are_returned() { + let test_cases = [ + ExpectedColumnsTestCase { + description: "aliases and bare or qualified columns", + dialect: "duckdb", + sql: "SELECT 1 AS order_id, o.customer_id, amount FROM orders AS o", + expected_columns: Some(&["order_id", "customer_id", "amount"]), + }, + ExpectedColumnsTestCase { + description: "quoted identifiers keep authored spelling", + dialect: "snowflake", + sql: "SELECT 1 AS \"Order Id\", 'paid' AS Status", + expected_columns: Some(&["\"Order Id\"", "Status"]), + }, + ExpectedColumnsTestCase { + description: "bracketed identifiers keep authored spelling", + dialect: "tsql", + sql: "SELECT 1 AS [Order Id], 2 AS amount", + expected_columns: Some(&["[Order Id]", "amount"]), + }, + ExpectedColumnsTestCase { + description: "set operations use the first branch", + dialect: "duckdb", + sql: "SELECT 1 AS order_id, 10 AS amount UNION ALL SELECT 2, 20", + expected_columns: Some(&["order_id", "amount"]), + }, + ExpectedColumnsTestCase { + description: "star over aliased values uses the alias list", + dialect: "duckdb", + sql: "SELECT * FROM (VALUES (1, 'paid'), (2, 'open')) AS t(order_id, status)", + expected_columns: Some(&["order_id", "status"]), + }, + ExpectedColumnsTestCase { + description: "leading WITH uses the final projection", + dialect: "duckdb", + sql: "WITH picked AS (SELECT 1 AS a, 2 AS b) SELECT b FROM picked", + expected_columns: Some(&["b"]), + }, + ExpectedColumnsTestCase { + description: "non-ASCII text before an identifier keeps its spelling", + dialect: "duckdb", + sql: "SELECT '订单' AS label, 1 AS \"数量\"", + expected_columns: Some(&["label", "\"数量\""]), + }, + ExpectedColumnsTestCase { + description: "star from another relation is not explicit", + dialect: "duckdb", + sql: "SELECT * FROM orders", + expected_columns: None, + }, + ExpectedColumnsTestCase { + description: "star with exclusions is not explicit", + dialect: "duckdb", + sql: "SELECT * EXCLUDE (amount) FROM (VALUES (1, 2)) AS t(order_id, amount)", + expected_columns: None, + }, + ExpectedColumnsTestCase { + description: "unaliased expressions are not explicit", + dialect: "duckdb", + sql: "SELECT 1 AS order_id, amount + 1 FROM orders", + expected_columns: None, + }, + ExpectedColumnsTestCase { + description: "duplicate names keep full-row comparison", + dialect: "duckdb", + sql: "SELECT 1 AS order_id, 2 AS ORDER_ID", + expected_columns: None, + }, + ExpectedColumnsTestCase { + description: "unparsable SQL is not explicit", + dialect: "duckdb", + sql: "SELECT FROM WHERE", + expected_columns: None, + }, + ]; + + for test_case in test_cases { + let dialect = render_dialect(Some(test_case.dialect)); + let actual: Option> = expected_columns(test_case.sql, &dialect); + let actual_columns: Option> = actual + .as_ref() + .map(|columns| columns.iter().map(String::as_str).collect()); + assert_eq!( + actual_columns.as_deref(), + test_case.expected_columns, + "{}", + test_case.description + ); + } +} diff --git a/crates/sqlbuild-rules-native/src/compiler/tests/test_sql_test_planning.rs b/crates/sqlbuild-rules-native/src/compiler/tests/test_sql_test_planning.rs index c541e0c169..27303541d8 100644 --- a/crates/sqlbuild-rules-native/src/compiler/tests/test_sql_test_planning.rs +++ b/crates/sqlbuild-rules-native/src/compiler/tests/test_sql_test_planning.rs @@ -1,10 +1,30 @@ use crate::compiler::tests::helpers::{ - concurrent_requests_initialize_shared_template_once, model_test_batch_returns_ordered_artifact, + chain_resolution_orders_unmocked_models, concurrent_requests_initialize_shared_template_once, + deep_shared_graph_reports_missing_mock_once, long_chain_plan_output_stays_linear, + model_test_batch_returns_ordered_artifact, plan_without_rendering_returns_executable_steps, + shared_textual_chain_renders_each_model_once, unicode_cte_after_leading_with_preserves_identifier, - unresolved_reference_fast_rejection_preserves_warning, + unresolved_reference_fast_rejection_preserves_warning, upstream_fallback_resolves, }; use crate::compiler::tests::test_types::SqlTestPlanningTestCase; +#[test] +fn given_sql_test_upstream_fallback_when_planning_then_descendants_and_assertions_resolve() { + let test_cases = [SqlTestPlanningTestCase { + description: "textual upstream steps remain available to descendants and assertions", + run: upstream_fallback_resolves, + expected_success: true, + }]; + for test_case in test_cases { + assert_eq!( + (test_case.run)(), + test_case.expected_success, + "{}", + test_case.description + ); + } +} + #[test] fn given_sql_test_planning_cases_when_exercising_native_planner_then_expected_behavior_holds() { let test_cases = [ @@ -23,11 +43,36 @@ fn given_sql_test_planning_cases_when_exercising_native_planner_then_expected_be run: unicode_cte_after_leading_with_preserves_identifier, expected_success: true, }, + SqlTestPlanningTestCase { + description: "shared textual chain renders each upstream model once", + run: shared_textual_chain_renders_each_model_once, + expected_success: true, + }, SqlTestPlanningTestCase { description: "fast rejection preserves unresolved reference warnings", run: unresolved_reference_fast_rejection_preserves_warning, expected_success: true, }, + SqlTestPlanningTestCase { + description: "deep shared graph reports one missing mock once", + run: deep_shared_graph_reports_missing_mock_once, + expected_success: true, + }, + SqlTestPlanningTestCase { + description: "planning without rendering returns executable steps", + run: plan_without_rendering_returns_executable_steps, + expected_success: true, + }, + SqlTestPlanningTestCase { + description: "long chain plan output stays linear", + run: long_chain_plan_output_stays_linear, + expected_success: true, + }, + SqlTestPlanningTestCase { + description: "chain resolution orders unmocked models", + run: chain_resolution_orders_unmocked_models, + expected_success: true, + }, ]; for test_case in test_cases { diff --git a/crates/sqlbuild-rules-native/src/compiler/tests/test_sql_test_rendering.rs b/crates/sqlbuild-rules-native/src/compiler/tests/test_sql_test_rendering.rs index 35b22cdcc2..43e2a531b5 100644 --- a/crates/sqlbuild-rules-native/src/compiler/tests/test_sql_test_rendering.rs +++ b/crates/sqlbuild-rules-native/src/compiler/tests/test_sql_test_rendering.rs @@ -1,13 +1,46 @@ -use crate::compiler::tests::helpers::ordered_comparison_batch_preserves_order; +use crate::compiler::tests::helpers::{ + actual_probe_selects_zero_rows_from_step, + difference_sample_lifts_generated_ctes_and_bounds_rows, + ordered_comparison_batch_preserves_order, partial_expected_columns_project_both_sides, + snowflake_plan_keeps_quoted_expected_columns, + sqlserver_difference_sample_projects_bracketed_columns, +}; use crate::compiler::tests::test_types::SqlTestRenderingTestCase; #[test] fn given_sql_rendering_cases_when_rendering_native_batches_then_expected_behavior_holds() { - let test_cases = [SqlTestRenderingTestCase { - description: "ordered comparison batches preserve SQL order", - run: ordered_comparison_batch_preserves_order, - expected_success: true, - }]; + let test_cases = [ + SqlTestRenderingTestCase { + description: "ordered comparison batches preserve SQL order", + run: ordered_comparison_batch_preserves_order, + expected_success: true, + }, + SqlTestRenderingTestCase { + description: "difference samples lift generated CTEs and bound rows", + run: difference_sample_lifts_generated_ctes_and_bounds_rows, + expected_success: true, + }, + SqlTestRenderingTestCase { + description: "partial expected columns project both comparison sides", + run: partial_expected_columns_project_both_sides, + expected_success: true, + }, + SqlTestRenderingTestCase { + description: "actual probe selects zero rows from the step", + run: actual_probe_selects_zero_rows_from_step, + expected_success: true, + }, + SqlTestRenderingTestCase { + description: "SQL Server difference sample projects bracketed columns", + run: sqlserver_difference_sample_projects_bracketed_columns, + expected_success: true, + }, + SqlTestRenderingTestCase { + description: "Snowflake plan keeps quoted expected columns", + run: snowflake_plan_keeps_quoted_expected_columns, + expected_success: true, + }, + ]; for test_case in test_cases { let actual_success = (test_case.run)(); diff --git a/crates/sqlbuild-rules-native/src/compiler/tests/test_types.rs b/crates/sqlbuild-rules-native/src/compiler/tests/test_types.rs index 33c12780a5..ee2a9052a0 100644 --- a/crates/sqlbuild-rules-native/src/compiler/tests/test_types.rs +++ b/crates/sqlbuild-rules-native/src/compiler/tests/test_types.rs @@ -27,3 +27,10 @@ pub(crate) struct SqlTestRenderingTestCase { pub(crate) run: fn() -> bool, pub(crate) expected_success: bool, } + +pub(crate) struct ExpectedColumnsTestCase { + pub(crate) description: &'static str, + pub(crate) dialect: &'static str, + pub(crate) sql: &'static str, + pub(crate) expected_columns: Option<&'static [&'static str]>, +} diff --git a/crates/sqlbuild-rules-native/src/sql_lint/_helpers/formatter.rs b/crates/sqlbuild-rules-native/src/sql_lint/_helpers/formatter.rs index ab39f208af..ecc51bb577 100644 --- a/crates/sqlbuild-rules-native/src/sql_lint/_helpers/formatter.rs +++ b/crates/sqlbuild-rules-native/src/sql_lint/_helpers/formatter.rs @@ -1,3 +1,4 @@ +use std::collections::BTreeMap; use std::str::FromStr; use polyglot_sql::tokens::Token; @@ -139,18 +140,32 @@ fn format_once(neutral_sql: &str, context: &FormatOnceContext<'_>) -> Result = Vec::new(); + let mut insertions: Vec = Vec::new(); + let mut leading_groups: BTreeMap> = BTreeMap::new(); for comment in context.comments { - insertions.push(comment_insertion( + if let Some(target) = leading_comment_target(context, comment) { + leading_groups.entry(target).or_default().push(comment); + } else { + insertions.push(trailing_comment_insertion( + &formatted, + &formatted_tokens, + context, + comment, + )); + } + } + for (target, comments) in &leading_groups { + insertions.push(leading_comment_insertion( &formatted, - &formatted_tokens, - context, - comment, + &formatted_tokens[*target], + comments, )); } - insertions.sort_by_key(|(offset, _)| *offset); - for (char_offset, text) in insertions.into_iter().rev() { - formatted.insert_str(char_to_byte(&formatted, char_offset)?, &text); + insertions.sort_by_key(|insertion| insertion.start); + for insertion in insertions.into_iter().rev() { + let start = char_to_byte(&formatted, insertion.start)?; + let end = char_to_byte(&formatted, insertion.end)?; + formatted.replace_range(start..end, &insertion.text); } Ok(formatted) } @@ -239,25 +254,80 @@ fn token_text(sql: &str, token: &Token) -> Option { char_slice(sql, token.span.start, token.span.end).map(|value| value.to_ascii_uppercase()) } -fn comment_insertion( +struct CommentInsertion { + start: usize, + end: usize, + text: String, +} + +fn leading_comment_target(context: &FormatOnceContext<'_>, comment: &Comment) -> Option { + if !comment.leading { + return None; + } + context + .original_tokens + .iter() + .position(|token| token.span.start >= comment.end) +} + +/// Place a block of leading comments on their own lines directly above the token they explain. +fn leading_comment_insertion( + formatted: &str, + target: &Token, + comments: &[&Comment], +) -> CommentInsertion { + let characters: Vec = formatted.chars().collect(); + let target_start = target.span.start.min(characters.len()); + let mut whitespace_start = target_start; + while whitespace_start > 0 + && characters[whitespace_start - 1].is_whitespace() + && characters[whitespace_start - 1] != '\n' + { + whitespace_start -= 1; + } + let at_line_start = whitespace_start == 0 || characters[whitespace_start - 1] == '\n'; + if at_line_start { + let indentation = " ".repeat(target_start - whitespace_start); + let text: String = comments + .iter() + .map(|comment| format!("{}\n{indentation}", comment.text)) + .collect(); + return CommentInsertion { + start: target_start, + end: target_start, + text, + }; + } + let indentation = " ".repeat(line_indentation(&characters, whitespace_start)); + let text: String = comments + .iter() + .map(|comment| format!("\n{indentation}{}", comment.text)) + .chain(std::iter::once(format!("\n{indentation}"))) + .collect(); + CommentInsertion { + start: whitespace_start, + end: target_start, + text, + } +} + +fn line_indentation(characters: &[char], offset: usize) -> usize { + let line_start = characters[..offset] + .iter() + .rposition(|character| *character == '\n') + .map_or(0, |position| position + 1); + characters[line_start..offset] + .iter() + .take_while(|character| **character == ' ' || **character == '\t') + .count() +} + +fn trailing_comment_insertion( formatted: &str, formatted_tokens: &[Token], context: &FormatOnceContext<'_>, comment: &Comment, -) -> (usize, String) { - if comment.leading - && let Some(next) = context - .original_tokens - .iter() - .position(|token| token.span.start >= comment.end) - { - let target = &formatted_tokens[next]; - let indentation = indentation_before(formatted, target.span.start); - return ( - target.span.start, - format!("{}\n{}", comment.text, " ".repeat(indentation)), - ); - } +) -> CommentInsertion { let previous = context .original_tokens .iter() @@ -270,26 +340,11 @@ fn comment_insertion( } else { "" }; - ( - char_offset, - format!("{separator}{}{terminator}", comment.text), - ) -} - -fn indentation_before(value: &str, char_offset: usize) -> usize { - let characters: Vec = value.chars().collect(); - let mut index = char_offset.min(characters.len()); - let mut indentation = 0_usize; - while index > 0 { - index -= 1; - if characters[index] == '\n' { - break; - } - if characters[index].is_whitespace() { - indentation += 1; - } + CommentInsertion { + start: char_offset, + end: char_offset, + text: format!("{separator}{}{terminator}", comment.text), } - indentation } fn restore_string_literals( diff --git a/crates/sqlbuild-rules-native/src/sql_lint/tests.rs b/crates/sqlbuild-rules-native/src/sql_lint/tests.rs index 086e1c5203..9c8b2cf639 100644 --- a/crates/sqlbuild-rules-native/src/sql_lint/tests.rs +++ b/crates/sqlbuild-rules-native/src/sql_lint/tests.rs @@ -1,5 +1,7 @@ #[path = "tests/test_batch_lint.rs"] mod batch_lint; +#[path = "tests/test_comment_formatter.rs"] +mod comment_formatter; #[path = "tests/test_empty_fixture_lint.rs"] mod empty_fixture_lint; #[path = "tests/helpers.rs"] diff --git a/crates/sqlbuild-rules-native/src/sql_lint/tests/test_comment_formatter.rs b/crates/sqlbuild-rules-native/src/sql_lint/tests/test_comment_formatter.rs new file mode 100644 index 0000000000..940b1301ae --- /dev/null +++ b/crates/sqlbuild-rules-native/src/sql_lint/tests/test_comment_formatter.rs @@ -0,0 +1,53 @@ +use serde_json::{Value, json}; + +use crate::sql_lint::main::formatter::format_json; +use crate::sql_lint::tests::test_types; + +#[test] +fn given_leading_cte_comments_when_formatting_then_comments_stay_above_their_cte() +-> Result<(), String> { + let test_cases = [ + test_types::FormatTestCase { + description: "leading comment before a middle CTE stays above it", + sql: "WITH\nupstream AS (\n SELECT * FROM orders\n),\n\n-- Explains the next CTE: line one,\n-- line two.\ndistinct_rows AS (\n SELECT order_id FROM upstream GROUP BY ALL\n)\n\nSELECT order_id FROM distinct_rows", + expected_sql: "WITH upstream AS (\n SELECT\n *\n FROM orders\n),\n-- Explains the next CTE: line one,\n-- line two.\ndistinct_rows AS (\n SELECT\n order_id\n FROM upstream\n GROUP BY ALL\n)\nSELECT\n order_id\nFROM distinct_rows", + expected_changed: true, + }, + test_types::FormatTestCase { + description: "formatted leading CTE comment is idempotent", + sql: "WITH upstream AS (\n SELECT\n *\n FROM orders\n),\n-- Explains the next CTE: line one,\n-- line two.\ndistinct_rows AS (\n SELECT\n order_id\n FROM upstream\n GROUP BY ALL\n)\nSELECT\n order_id\nFROM distinct_rows", + expected_sql: "WITH upstream AS (\n SELECT\n *\n FROM orders\n),\n-- Explains the next CTE: line one,\n-- line two.\ndistinct_rows AS (\n SELECT\n order_id\n FROM upstream\n GROUP BY ALL\n)\nSELECT\n order_id\nFROM distinct_rows", + expected_changed: false, + }, + test_types::FormatTestCase { + description: "leading comment before the first CTE stays above it", + sql: "WITH\n-- Explains the first CTE.\nupstream AS (SELECT * FROM orders)\nSELECT order_id FROM upstream", + expected_sql: "WITH\n-- Explains the first CTE.\nupstream AS (\n SELECT\n *\n FROM orders\n)\nSELECT\n order_id\nFROM upstream", + expected_changed: true, + }, + test_types::FormatTestCase { + description: "leading comment before the last of three CTEs stays above it", + sql: "WITH a AS (SELECT 1 AS x),\nb AS (SELECT x FROM a),\n /* Explains the last CTE. */\nc AS (SELECT x FROM b)\nSELECT x FROM c", + expected_sql: "WITH a AS (\n SELECT\n 1 AS x\n), b AS (\n SELECT\n x\n FROM a\n),\n/* Explains the last CTE. */\nc AS (\n SELECT\n x\n FROM b\n)\nSELECT\n x\nFROM c", + expected_changed: true, + }, + ]; + + for test_case in &test_cases { + let response = format_json( + &json!({"version": 1, "sql": test_case.sql, "dialect": "snowflake"}).to_string(), + )?; + let payload: Value = serde_json::from_str(&response).map_err(|error| error.to_string())?; + assert_eq!( + payload["sql"], test_case.expected_sql, + "{}", + test_case.description + ); + assert_eq!( + payload["changed"], test_case.expected_changed, + "{}", + test_case.description + ); + } + Ok(()) +} diff --git a/scripts/cli_preview/_helpers/workflow.py b/scripts/cli_preview/_helpers/workflow.py index 33de578d38..20afa082ee 100644 --- a/scripts/cli_preview/_helpers/workflow.py +++ b/scripts/cli_preview/_helpers/workflow.py @@ -17,8 +17,11 @@ _PAYMENTS_MODEL_PATH: Path = Path("models/staging/stg_payments.sql") _VIRTUAL_MODEL_PATH: Path = Path("models/fact_waffle_orders.sql") -_QUERY_MARKER: str = "SELECT\n" -_CHANGED_QUERY_MARKER: str = "SELECT\n 1 AS preview_change,\n" +_QUERY_MARKER: str = " amount_cents,\n" +_CHANGED_QUERY_MARKER: str = ( + " CAST(CASE WHEN payment_method = 'card' THEN 'preview'" + " ELSE CAST(amount_cents AS VARCHAR) END AS INTEGER) AS amount_cents,\n" +) _VIRTUAL_QUERY_FILTER: str = "WHERE o.order_id IS NOT NULL" _JANITOR_SECTION_MARKER: str = "[janitor]" _JANITOR_CONFIG: str = "\n[janitor]\nenabled = true\nretention_days = 0\n" diff --git a/src/sqlbuild/.agents/skills/sqlbuild/SKILL.md b/src/sqlbuild/.agents/skills/sqlbuild/SKILL.md index 47cb70d9ba..dcc7473a26 100644 --- a/src/sqlbuild/.agents/skills/sqlbuild/SKILL.md +++ b/src/sqlbuild/.agents/skills/sqlbuild/SKILL.md @@ -115,7 +115,8 @@ window into batches, each with its own audits. Always `sqb plan` an incremental **Tests.** SQL unit tests live under `tests/unit/`. A `TEST();` file mocks inputs with `__source__`, `__ref__`, `__seed__` CTEs, states expected output with -`__expected__`, adds zero-row checks with `__assert__`, and ends with `SELECT 1`. +`__expected__` (only the listed columns are compared, matched by name; unlisted columns are +ignored), adds zero-row checks with `__assert__`, and ends with `SELECT 1`. One test can span many models: mock the sources, assert on the final model, and every intermediate model runs from its real SQL. Read [references/testing.md](references/testing.md) for fixtures, parameterised cases, scenarios, audits and Rules. diff --git a/src/sqlbuild/.agents/skills/sqlbuild/references/exploring-a-project.md b/src/sqlbuild/.agents/skills/sqlbuild/references/exploring-a-project.md index 4ddd5bed19..b1c4d8a4d2 100644 --- a/src/sqlbuild/.agents/skills/sqlbuild/references/exploring-a-project.md +++ b/src/sqlbuild/.agents/skills/sqlbuild/references/exploring-a-project.md @@ -66,12 +66,16 @@ sqb lineage fact_orders # upstream tree (default) sqb lineage fact_orders --direction both # upstream and downstream sqb lineage fact_orders --direction downstream --depth 1 sqb lineage --select tag:finance --format list # edges for a selection +sqb lineage fact_orders dim_customers --depth 1 --format list # direct parents of several models +sqb lineage --select tag:finance --direction upstream --depth 1 # expand a selection sqb lineage fact_orders.total_cents # where a column comes from sqb lineage fact_orders.order_id --direction downstream # who consumes a column ``` -- Targets are resources (models, sources, seeds, functions). `model.column` switches to column - lineage. +- Targets are resources (models, sources, seeds, functions), optionally with the printed kind + prefix (`model:fact_orders`). `model.column` switches to column lineage (one column per call). +- Graph operators such as `1+fact_orders` are not lineage targets: use `--direction` and + `--depth`. Without `--direction`, `--select` shows only edges inside the selection. - Formats: `tree` (default), `list` (edges), `json` (nodes, edges, `qualified_name`). - Column edges carry a transform type (`direct`, `expression`, `aggregation`, `cast`, `star`, `constant`) and a confidence level. `--mode fast` trades detail for speed on large projects. diff --git a/src/sqlbuild/.agents/skills/sqlbuild/references/testing.md b/src/sqlbuild/.agents/skills/sqlbuild/references/testing.md index 6a3bbb36cb..5a33b9856d 100644 --- a/src/sqlbuild/.agents/skills/sqlbuild/references/testing.md +++ b/src/sqlbuild/.agents/skills/sqlbuild/references/testing.md @@ -48,10 +48,15 @@ SELECT 1 | `__seed__` | Mock `__seed("")` | | `__table_fn__` | Mock a table function invocation | | `__macro__` | Replace every `@(...)` call | -| `__expected__` | Expected output of the model's real SQL (exact rows, compared both ways) | +| `__expected__` | Expected rows of the model's real SQL, compared both ways on the listed columns only | | `__assert__` | Passes when the query returns zero rows | | anything else | Helper CTE visible to mocks and model SQL | +An `__expected__` CTE compares only the columns it lists, matched by name, so column order +does not matter and unlisted model columns are ignored. Row counts are always compared. Listing a +column the model does not output is a clear test error. When the expected CTE's columns are not +explicit (for example `SELECT *` from another CTE), SQLBuild compares every column by position. + Macros work inside tests, so reusable mock generators such as `@mock_orders(count=5)` are normal. Tests can also target a macro, UDF or table function directly; see [docs/concepts/testing.md](docs/concepts/testing.md) for those modes. diff --git a/src/sqlbuild/cli/commands/_helpers/compile/output.py b/src/sqlbuild/cli/commands/_helpers/compile/output.py index 9ac6c7b8ef..7edd8c5456 100644 --- a/src/sqlbuild/cli/commands/_helpers/compile/output.py +++ b/src/sqlbuild/cli/commands/_helpers/compile/output.py @@ -484,7 +484,9 @@ def _models_with_error_diagnostics( return frozenset( diagnostic.resource_name for diagnostic in diagnostics - if diagnostic.is_error and diagnostic.resource_name is not None + if diagnostic.is_error + and diagnostic.resource_name is not None + and diagnostic.resource_type != CompiledResourceType.SQL_TEST ) diff --git a/src/sqlbuild/cli/commands/_helpers/compile/target_writer.py b/src/sqlbuild/cli/commands/_helpers/compile/target_writer.py index 35cf8435e8..1fd649e2f3 100644 --- a/src/sqlbuild/cli/commands/_helpers/compile/target_writer.py +++ b/src/sqlbuild/cli/commands/_helpers/compile/target_writer.py @@ -27,8 +27,19 @@ compiled_sql_test_output_path, ) from sqlbuild.cli.paths.main._sql_test_output_path import sql_test_output_path -from sqlbuild.compiler.compile.models import CompiledObjectKey, CompiledProject, CompiledSqlTest -from sqlbuild.compiler.compile.types import FunctionLanguage +from sqlbuild.compiler.compile.models import ( + CompiledObjectKey, + CompiledProject, + CompiledSqlTest, + CompilerDiagnostic, +) +from sqlbuild.compiler.compile.types import ( + CompiledResourceType, + DiagnosticPhase, + DiagnosticSeverity, + FunctionLanguage, +) +from sqlbuild.compiler.planner.exceptions import PlannerInputError from sqlbuild.compiler.planner.main.execution.sql_test_artifacts import ( plan_and_render_sql_test_artifacts, ) @@ -38,6 +49,7 @@ from sqlbuild.compiler.planner.models import AuditPlanEntry, NativeSqlTestArtifact, PlanOutput from sqlbuild.compiler.profiling.main.record import record_compile_timing from sqlbuild.executor.testing.main.comparison_sql import build_sql_test_comparison_sql +from sqlbuild.runtime.observability.classes.operation_lifecycle import OperationLifecycle _COMPILED_DIR: str = "compiled" _RUN_DIR: str = "run" @@ -51,6 +63,7 @@ _MANIFEST_FILE: str = "manifest.json" _SQL_FILE_SUFFIX: str = ".sql" _POSIX_LINE_SEPARATOR: str = "\n" +_SQL_TEST_PLANNING_ERROR_CODE: str = PlannerInputError.code def write_compile_target( @@ -132,13 +145,14 @@ def write_static_compile_target( project=project, check_existing=remove_stale_files, ), - _write_static_tests( - target_dir=target_dir, - adapter=adapter, - project=project, - check_existing=remove_stale_files, - ), ) + test_paths, test_diagnostics = _write_static_tests( + target_dir=target_dir, + adapter=adapter, + project=project, + check_existing=remove_stale_files, + ) + managed_paths.update(test_paths) if remove_stale_files: with record_compile_timing("stale_traversal_ms"): _remove_stale_compiled_files(target_dir=target_dir, managed_paths=managed_paths) @@ -152,6 +166,7 @@ def write_static_compile_target( audit_count=len(project.audits), test_count=len(project.sql_tests), target_dir=target_dir, + diagnostics=test_diagnostics, ) @@ -185,6 +200,7 @@ def publish_static_compile_target( audit_count=prepared.audit_count, test_count=prepared.test_count, target_dir=target_dir, + diagnostics=prepared.diagnostics, ) @@ -351,10 +367,11 @@ def _write_static_tests( adapter: BaseAdapter, project: CompiledProject, check_existing: bool, -) -> set[Path]: - """Write offline SQL-native test SQL.""" +) -> tuple[set[Path], tuple[CompilerDiagnostic, ...]]: + """Write offline SQL-native test SQL and report uncached planning errors.""" managed_paths: set[Path] = set() + diagnostics: list[CompilerDiagnostic] = [] tests_root: Path = target_dir / _COMPILED_DIR / _TESTS_DIR cached_records: dict[str, SqlTestArtifactCacheRecord] = read_sql_test_artifact_cache( cache_dir=project.compile_cache_dir @@ -397,12 +414,16 @@ def _write_static_tests( native_artifacts: tuple[NativeSqlTestArtifact, ...] = () if pending: - native_artifacts = plan_and_render_sql_test_artifacts( - project=project, - tests=tuple(test for test, _, _ in pending), - adapter=adapter, - sql_analysis_enabled=project.settings.sql_analysis, - ) + with OperationLifecycle( + operation_kind="project", operation_name="sql_test_planning" + ) as lifecycle: + native_artifacts = plan_and_render_sql_test_artifacts( + project=project, + tests=tuple(test for test, _, _ in pending), + adapter=adapter, + sql_analysis_enabled=project.settings.sql_analysis, + ) + lifecycle.completed(metadata={"item_count": len(native_artifacts)}) for (test, record_key, artifact_identity), artifact in zip( pending, native_artifacts, strict=True ): @@ -412,6 +433,12 @@ def _write_static_tests( ) _write_sql(path=test_path, sql=artifact.sql, check_existing=check_existing) managed_paths.add(test_path) + if artifact.error_messages: + diagnostics.extend( + _sql_test_error_diagnostic(test=test, message=message) + for message in artifact.error_messages + ) + continue if record_key is not None and artifact_identity is not None: record: SqlTestArtifactCacheRecord | None = build_sql_test_artifact_cache_record( tests_root=tests_root, @@ -425,7 +452,19 @@ def _write_static_tests( cache_dir=project.compile_cache_dir, records=current_records, ) - return managed_paths + return managed_paths, tuple(diagnostics) + + +def _sql_test_error_diagnostic(*, test: CompiledSqlTest, message: str) -> CompilerDiagnostic: + return CompilerDiagnostic( + phase=DiagnosticPhase.TEST, + severity=DiagnosticSeverity.ERROR, + code=_SQL_TEST_PLANNING_ERROR_CODE, + message=message, + resource_type=CompiledResourceType.SQL_TEST, + resource_name=test.name, + path=test.source_path, + ) def _write_manifest(*, target_dir: Path, manifest: dict[str, object]) -> None: diff --git a/src/sqlbuild/cli/commands/_helpers/entry/dispatch.py b/src/sqlbuild/cli/commands/_helpers/entry/dispatch.py index d9022f4410..1c5e10c7b6 100644 --- a/src/sqlbuild/cli/commands/_helpers/entry/dispatch.py +++ b/src/sqlbuild/cli/commands/_helpers/entry/dispatch.py @@ -6,6 +6,7 @@ from sqlbuild.cli.commands._helpers.diff.validation import parse_diff_name_range from sqlbuild.cli.commands._helpers.entry.parsing import read_selector_file_inputs +from sqlbuild.cli.commands._helpers.lint.positional_paths import format_path_selectors from sqlbuild.cli.commands.classes.cli_namespace import CliNamespace from sqlbuild.cli.commands.constants import ( DBT_INIT_COMMAND, @@ -293,7 +294,7 @@ def dispatch_cli_command(*, args: CliNamespace, handlers: CliEntrypointHandlers) LineageCommandRequest( project_dir=project_dir, no_sql_validation=args.no_sql_validation, - target=args.lineage_target, + targets=tuple(args.lineage_targets), output_format=args.lineage_format, direction=args.lineage_direction, depth=args.lineage_depth, @@ -610,9 +611,12 @@ def _dispatch_lint_format_command( """Route the source-formatting command to its handler.""" if args.format_diff and args.json: raise CliUserError("format --diff cannot be combined with --json", code="C112") + path_selectors: tuple[str, ...] = format_path_selectors( + paths=tuple(args.format_paths), project_dir=project_dir, cwd=Path.cwd() + ) return handlers.run_format( project_dir, - select=select, + select=(*select, *path_selectors), exclude=tuple(args.exclude), check=args.format_check, diff=args.format_diff, diff --git a/src/sqlbuild/cli/commands/_helpers/entry/parser.py b/src/sqlbuild/cli/commands/_helpers/entry/parser.py index f46a51f1a4..37d29e7e7c 100644 --- a/src/sqlbuild/cli/commands/_helpers/entry/parser.py +++ b/src/sqlbuild/cli/commands/_helpers/entry/parser.py @@ -315,6 +315,13 @@ def _add_quality_parsers( action="store_true", help="only remove redundant typed-null SQL test fixture columns", ) + format_parser.add_argument( + "format_paths", + nargs="*", + default=[], + metavar="PATH", + help="SQL files or folders to format, relative to the current directory", + ) _ = add_select_args(format_parser) @@ -581,7 +588,14 @@ def _add_inspection_parsers( ) lineage_parser: argparse.ArgumentParser = subparsers.add_parser(CliCommand.LINEAGE) - lineage_parser.add_argument("lineage_target", nargs="?", metavar="target") + lineage_parser.add_argument( + "lineage_targets", + nargs="*", + default=[], + metavar="target", + help="models, sources or seeds (optionally kind-prefixed, e.g. model:orders), " + "or one model.column", + ) _add_sql_analysis_override(lineage_parser) lineage_parser.add_argument( "--format", @@ -600,7 +614,7 @@ def _add_inspection_parsers( "--direction", dest="lineage_direction", choices=("upstream", "downstream", "both"), - default="upstream", + default=None, ) lineage_parser.add_argument("--depth", dest="lineage_depth", default="all") lineage_parser.add_argument( diff --git a/src/sqlbuild/cli/commands/_helpers/lineage/execution.py b/src/sqlbuild/cli/commands/_helpers/lineage/execution.py index 259dead1e5..2c8e1f2f5a 100644 --- a/src/sqlbuild/cli/commands/_helpers/lineage/execution.py +++ b/src/sqlbuild/cli/commands/_helpers/lineage/execution.py @@ -20,6 +20,7 @@ format_lineage_tree, ) from sqlbuild.cli.commands._helpers.lineage.selection import ( + normalize_lineage_target, parse_depth, select_column_target_lineage, select_selector_lineage, @@ -30,6 +31,7 @@ COLUMN_TARGET_SEPARATOR, JSON_OUTPUT_FORMAT, LIST_OUTPUT_FORMAT, + UPSTREAM_DIRECTION, ) from sqlbuild.cli.commands.exceptions import CliUserError from sqlbuild.cli.commands.models import ( @@ -72,10 +74,18 @@ def execute_lineage(*, request: LineageCommandRequest) -> int: def _validate_request(*, request: LineageCommandRequest) -> None: - if request.target is not None and request.select: - raise CliUserError("lineage accepts either a target or --select, not both", code="C301") - if request.target is None and not request.select: + if request.targets and request.select: + raise CliUserError("lineage accepts either targets or --select, not both", code="C301") + if not request.targets and not request.select: raise CliUserError("lineage requires a target or --select", code="C302") + if len(request.targets) > 1 and any( + COLUMN_TARGET_SEPARATOR in target for target in request.targets + ): + raise CliUserError( + "column lineage accepts exactly one model.column target", + code="C320", + help="run one lineage command per column", + ) if request.exclude and not request.select: raise CliUserError("--exclude can only be used with --select", code="C303") @@ -131,8 +141,8 @@ def _prepare_graph( def _requires_compiled_graph(*, request: LineageCommandRequest) -> bool: - return request.include_uses or ( - request.target is not None and COLUMN_TARGET_SEPARATOR in request.target + return request.include_uses or any( + COLUMN_TARGET_SEPARATOR in target for target in request.targets ) @@ -162,20 +172,28 @@ def _render_lineage( graph: ProjectGraph | RelationLineageIndex, parsed_depth: int | None, ) -> str: - if request.target is not None: - column_trace: ColumnLineageTrace | None = select_column_target_lineage( - graph=graph, - target=request.target, - direction=request.direction, - depth=parsed_depth, - mode=request.lineage_mode, + if request.targets: + targets: tuple[str, ...] = tuple( + normalize_lineage_target(graph=graph, target=target) for target in request.targets + ) + direction: str = request.direction or UPSTREAM_DIRECTION + column_trace: ColumnLineageTrace | None = ( + select_column_target_lineage( + graph=graph, + target=targets[0], + direction=direction, + depth=parsed_depth, + mode=request.lineage_mode, + ) + if len(targets) == 1 + else None ) if column_trace is not None: return _format_column_trace(trace=column_trace, output_format=request.output_format) lineage_graph: LineageGraph = select_target_lineage( graph=graph, - target=request.target, - direction=request.direction, + targets=targets, + direction=direction, depth=parsed_depth, ) else: @@ -184,6 +202,7 @@ def _render_lineage( select=request.select, exclude=request.exclude, depth=parsed_depth, + direction=request.direction, ) return _format_graph(graph=lineage_graph, output_format=request.output_format) @@ -207,8 +226,13 @@ def _format_graph(*, graph: LineageGraph, output_format: str) -> str: def _semantic_use_model_names( *, request: LineageCommandRequest, graph: ProjectGraph ) -> frozenset[str]: - if request.target is not None: - return frozenset({request.target.split(".", maxsplit=1)[0]}) + if request.targets: + return frozenset( + normalize_lineage_target(graph=graph, target=target).split( + COLUMN_TARGET_SEPARATOR, maxsplit=1 + )[0] + for target in request.targets + ) keys: frozenset[CompiledObjectKey] = resolve_project_selectors( select=request.select, exclude=request.exclude, diff --git a/src/sqlbuild/cli/commands/_helpers/lineage/selection.py b/src/sqlbuild/cli/commands/_helpers/lineage/selection.py index 6a38131a25..5f93cd9f6b 100644 --- a/src/sqlbuild/cli/commands/_helpers/lineage/selection.py +++ b/src/sqlbuild/cli/commands/_helpers/lineage/selection.py @@ -52,6 +52,19 @@ class _ColumnLineageCandidateSelection: truncated: bool +_KIND_SEPARATOR: str = ":" +_GRAPH_OPERATORS: tuple[str, ...] = ("+", "@", "*") +_TARGET_KINDS: frozenset[str] = frozenset( + { + str(CompiledResourceType.MODEL), + str(CompiledResourceType.SOURCE), + str(CompiledResourceType.SEED), + str(CompiledResourceType.UDF), + str(CompiledResourceType.TABLE_FN), + } +) + + def parse_depth(raw_depth: str) -> int | None: """Parse a lineage depth value, returning None for unlimited traversal.""" @@ -66,32 +79,71 @@ def parse_depth(raw_depth: str) -> int | None: return depth +def normalize_lineage_target(*, graph: ProjectGraph | RelationLineageIndex, target: str) -> str: + """Strip a printed kind prefix such as `model:` after checking it matches the resource.""" + + kind, separator, remainder = target.partition(_KIND_SEPARATOR) + if not separator or kind not in _TARGET_KINDS: + return target + name: str = remainder.split(COLUMN_TARGET_SEPARATOR, maxsplit=1)[0] + key: CompiledObjectKey | None = graph.all_keys.get(name) + if key is None or str(key.resource_type) != kind: + raise _unknown_target_error(target) + return remainder + + def select_target_lineage( *, graph: ProjectGraph | RelationLineageIndex, - target: str, + targets: tuple[str, ...], direction: str, depth: int | None, ) -> LineageGraph: - """Select lineage around one positional target.""" + """Select lineage around one or more positional targets.""" - key: CompiledObjectKey | None = graph.all_keys.get(target) - if key is None: - raise CliUserError(f"unknown lineage target '{target}'", code="C305") - - selected: set[CompiledObjectKey] = {key} - if direction in {UPSTREAM_DIRECTION, BOTH_DIRECTIONS}: - selected.update(_walk_bounded(anchors=(key,), deps=graph.upstream_deps, max_depth=depth)) - if direction in {DOWNSTREAM_DIRECTION, BOTH_DIRECTIONS}: - selected.update(_walk_bounded(anchors=(key,), deps=graph.downstream_deps, max_depth=depth)) + keys: tuple[CompiledObjectKey, ...] = tuple( + _lookup_name(name=target, all_keys=graph.all_keys) for target in targets + ) + selected: set[CompiledObjectKey] = set(keys) + selected.update(_expand(keys=keys, graph=graph, direction=direction, depth=depth)) return build_lineage_graph( graph=graph, selected_keys=frozenset(selected), - focus_keys=(key,), + focus_keys=tuple(sorted(set(keys), key=_sort_key)), direction=direction, ) +def _expand( + *, + keys: Iterable[CompiledObjectKey], + graph: ProjectGraph | RelationLineageIndex, + direction: str, + depth: int | None, +) -> frozenset[CompiledObjectKey]: + anchors: tuple[CompiledObjectKey, ...] = tuple(keys) + expanded: set[CompiledObjectKey] = set() + if direction in {UPSTREAM_DIRECTION, BOTH_DIRECTIONS}: + expanded.update(_walk_bounded(anchors=anchors, deps=graph.upstream_deps, max_depth=depth)) + if direction in {DOWNSTREAM_DIRECTION, BOTH_DIRECTIONS}: + expanded.update(_walk_bounded(anchors=anchors, deps=graph.downstream_deps, max_depth=depth)) + return frozenset(expanded) + + +def _unknown_target_error(target: str) -> CliUserError: + uses_graph_operator: bool = any(operator in target for operator in _GRAPH_OPERATORS) + return CliUserError( + f"unknown lineage target '{target}'", + code="C305", + help=( + "graph operators are not supported in lineage targets; " + "use --direction upstream|downstream|both and --depth N" + if uses_graph_operator + else "use a model, source or seed name, optionally prefixed like model:orders" + ), + ) + + def select_column_target_lineage( *, graph: ProjectGraph | RelationLineageIndex, @@ -247,8 +299,9 @@ def select_selector_lineage( select: tuple[str, ...], exclude: tuple[str, ...], depth: int | None, + direction: str | None = None, ) -> LineageGraph: - """Select lineage using existing selector semantics.""" + """Select lineage using selector semantics, expanding by direction when one is given.""" selected_keys: frozenset[CompiledObjectKey] = _resolve_selectors( select=select, @@ -259,6 +312,30 @@ def select_selector_lineage( tag_index=graph.tag_index, path_index=graph.path_index, ) + if direction is not None: + excluded_keys: set[CompiledObjectKey] = set() + for raw_exclude in exclude: + for token in raw_exclude.split(): + excluded_keys.update( + _resolve_token( + token=token, + all_keys=graph.all_keys, + upstream=graph.upstream_deps, + downstream=graph.downstream_deps, + tag_index=graph.tag_index, + path_index=graph.path_index, + ) + ) + return build_lineage_graph( + graph=graph, + selected_keys=( + selected_keys + | _expand(keys=selected_keys, graph=graph, direction=direction, depth=depth) + ) + - excluded_keys, + focus_keys=tuple(sorted(selected_keys - excluded_keys, key=_sort_key)), + direction=direction, + ) anchors: LineageSelectionAnchors = _resolve_selector_anchors( select=select, all_keys=graph.all_keys, @@ -444,7 +521,7 @@ def _walk_all( def _lookup_name(*, name: str, all_keys: dict[str, CompiledObjectKey]) -> CompiledObjectKey: key: CompiledObjectKey | None = all_keys.get(name) if key is None: - raise CliUserError(f"unknown lineage target '{name}'", code="C305") + raise _unknown_target_error(name) return key diff --git a/src/sqlbuild/cli/commands/_helpers/lint/positional_paths.py b/src/sqlbuild/cli/commands/_helpers/lint/positional_paths.py new file mode 100644 index 0000000000..fbe1e9168f --- /dev/null +++ b/src/sqlbuild/cli/commands/_helpers/lint/positional_paths.py @@ -0,0 +1,40 @@ +"""Translate `sqb format` positional paths into formatter path selectors.""" + +from __future__ import annotations + +from pathlib import Path + +from sqlbuild.cli.commands.exceptions import CliUserError +from sqlbuild.lint.constants import LINT_DIRECTORY_NAMES + +_FORMAT_PATH_ERROR_CODE: str = "C113" + + +def format_path_selectors( + *, paths: tuple[str, ...], project_dir: Path | None, cwd: Path +) -> tuple[str, ...]: + """Return one `path:` selector per positional path, relative to the project root.""" + + project_root: Path = (project_dir if project_dir is not None else cwd).resolve() + selectors: list[str] = [] + for raw_path in paths: + candidate: Path = Path(raw_path) + resolved: Path = (candidate if candidate.is_absolute() else cwd / candidate).resolve() + if not resolved.exists(): + raise CliUserError( + f"format path '{raw_path}' does not exist", code=_FORMAT_PATH_ERROR_CODE + ) + if not resolved.is_relative_to(project_root): + raise CliUserError( + f"format path '{raw_path}' is outside the project '{project_root}'", + code=_FORMAT_PATH_ERROR_CODE, + ) + relative: Path = resolved.relative_to(project_root) + if not relative.parts or relative.parts[0] not in LINT_DIRECTORY_NAMES: + raise CliUserError( + f"format path '{raw_path}' is not under a formatted folder", + code=_FORMAT_PATH_ERROR_CODE, + help="use a path under " + ", ".join(f"{name}/" for name in LINT_DIRECTORY_NAMES), + ) + selectors.append(f"path:{relative.as_posix()}") + return tuple(selectors) diff --git a/src/sqlbuild/cli/commands/_helpers/lint/selection.py b/src/sqlbuild/cli/commands/_helpers/lint/selection.py index 615f493922..e11c2ed751 100644 --- a/src/sqlbuild/cli/commands/_helpers/lint/selection.py +++ b/src/sqlbuild/cli/commands/_helpers/lint/selection.py @@ -26,6 +26,7 @@ resolve_effective_adapter_name, ) +_PATH_SEPARATOR: str = "/" _GRAPH_SELECTOR_MARKERS: tuple[str, ...] = ("+", ":", "~", "/", "\\", ",") @@ -190,6 +191,7 @@ def _partition_lint_selectors( model_tokens: list[str] = [] for raw_selector in raw_selectors: for token in raw_selector.split(): + _reject_unknown_lint_root(token) prefixes: tuple[str, ...] | None = _lint_path_prefixes(raw_selectors=(token,)) if prefixes is not None: path_tokens.extend(prefixes) @@ -258,7 +260,7 @@ def _lint_path_prefixes(*, raw_selectors: tuple[str, ...]) -> tuple[str, ...] | return None for token in raw_selector.split(): normalized: str = token.replace("\\", "/") - if not normalized.startswith("path:"): + if not normalized.startswith("path:") and not _is_bare_lint_path(normalized): return None prefix: str = normalized.removeprefix("path:").strip("/") if any(marker in prefix for marker in ("+", "~", ",")): @@ -270,6 +272,27 @@ def _lint_path_prefixes(*, raw_selectors: tuple[str, ...]) -> tuple[str, ...] | return tuple(prefixes) +def _is_bare_lint_path(token: str) -> bool: + """Treat `models/orders.sql`-style tokens as formatter paths without a `path:` prefix.""" + + first_segment: str = token.split(_PATH_SEPARATOR, 1)[0] + return _PATH_SEPARATOR in token and first_segment in LINT_DIRECTORY_NAMES + + +def _reject_unknown_lint_root(token: str) -> None: + normalized: str = token.replace("\\", "/") + if not normalized.startswith("path:"): + return + root: str = normalized.removeprefix("path:").strip(_PATH_SEPARATOR).split(_PATH_SEPARATOR, 1)[0] + if root in LINT_DIRECTORY_NAMES: + return + raise PlannerInputError( + "format path selectors must start with one of: " + + ", ".join(f"'{name}/'" for name in LINT_DIRECTORY_NAMES), + code="S012", + ) + + def _matches_path_prefix(*, relative_path: str, prefixes: tuple[str, ...]) -> bool: return any( relative_path == prefix or relative_path.startswith(f"{prefix}/") for prefix in prefixes diff --git a/src/sqlbuild/cli/commands/classes/cli_namespace.py b/src/sqlbuild/cli/commands/classes/cli_namespace.py index 3b81968715..38ca9e3714 100644 --- a/src/sqlbuild/cli/commands/classes/cli_namespace.py +++ b/src/sqlbuild/cli/commands/classes/cli_namespace.py @@ -96,9 +96,9 @@ "cost_order": None, "cost_since": None, "cost_until": None, - "lineage_target": None, + "lineage_targets": [], "lineage_format": "tree", - "lineage_direction": "upstream", + "lineage_direction": None, "lineage_depth": "all", "lineage_mode": ColumnLineageMode.RICH, "lineage_include_uses": False, @@ -154,6 +154,7 @@ "format_check": False, "format_diff": False, "format_fixtures_only": False, + "format_paths": [], "scope_target": None, "scope_at": None, "scope_as_path": None, @@ -270,9 +271,9 @@ class CliNamespace: cost_order: str | None cost_since: str | None cost_until: str | None - lineage_target: str | None + lineage_targets: list[str] lineage_format: str - lineage_direction: str + lineage_direction: str | None lineage_depth: str lineage_mode: ColumnLineageMode lineage_include_uses: bool @@ -328,6 +329,7 @@ class CliNamespace: format_check: bool format_diff: bool format_fixtures_only: bool + format_paths: list[str] scope_target: str | None scope_at: str | None scope_as_path: str | None diff --git a/src/sqlbuild/cli/commands/main/project/_compile.py b/src/sqlbuild/cli/commands/main/project/_compile.py index a69aeea2fc..ac87980508 100644 --- a/src/sqlbuild/cli/commands/main/project/_compile.py +++ b/src/sqlbuild/cli/commands/main/project/_compile.py @@ -24,7 +24,7 @@ CompileCommandRequest, CompileWriteResult, ) -from sqlbuild.compiler.compile.models import CompileAnalysisSelection +from sqlbuild.compiler.compile.models import CompileAnalysisSelection, CompilerDiagnostic from sqlbuild.compiler.compile.types import DiagnosticPhase from sqlbuild.compiler.profiling.main.collect import collect_compile_timings from sqlbuild.compiler.profiling.models import CompileTimingCollector @@ -126,7 +126,11 @@ def _run_compile_with_status( **detailed_timings.as_milliseconds(), "total_ms": elapsed_ms(total_start), } - exit_code: int = 1 if any(diagnostic.is_error for diagnostic in analysis.diagnostics) else 0 + diagnostics: tuple[CompilerDiagnostic, ...] = ( + *analysis.diagnostics, + *write_result.written.diagnostics, + ) + exit_code: int = 1 if any(diagnostic.is_error for diagnostic in diagnostics) else 0 if status is not None: status.close() @@ -140,7 +144,7 @@ def _run_compile_with_status( timings_ms=timings_ms, lineage=analysis.lineage, lineage_mode=lineage_mode, - diagnostics=analysis.diagnostics, + diagnostics=diagnostics, selected_keys=analysis.selected_keys, ) ) @@ -153,7 +157,7 @@ def _run_compile_with_status( manifest=manifest, lineage=analysis.lineage, lineage_mode=lineage_mode, - diagnostics=analysis.diagnostics, + diagnostics=diagnostics, selected_keys=analysis.selected_keys, use_color=(not no_color) and supports_color(), ) diff --git a/src/sqlbuild/cli/commands/models.py b/src/sqlbuild/cli/commands/models.py index 4b1893ca87..98f8c3ec2d 100644 --- a/src/sqlbuild/cli/commands/models.py +++ b/src/sqlbuild/cli/commands/models.py @@ -1332,9 +1332,9 @@ class LineageCommandRequest: project_dir: Path | None no_sql_validation: bool = False - target: str | None = None + targets: tuple[str, ...] = () output_format: str = "tree" - direction: str = "upstream" + direction: str | None = None depth: str = "all" select: tuple[str, ...] = () exclude: tuple[str, ...] = () diff --git a/src/sqlbuild/cli/output/_helpers/cursor_plan.py b/src/sqlbuild/cli/output/_helpers/cursor_plan.py index b83161febd..9bc11a0b7a 100644 --- a/src/sqlbuild/cli/output/_helpers/cursor_plan.py +++ b/src/sqlbuild/cli/output/_helpers/cursor_plan.py @@ -18,6 +18,7 @@ ) from sqlbuild.cursor_algebra.constants import GRAIN_ORDER from sqlbuild.cursor_algebra.models import DateValue, IntegerValue, TimestampValue +from sqlbuild.cursor_algebra.types import BoundSentinel from sqlbuild.spec.contracts.constants import EFFECTIVE_BATCH_SIZE_TOKEN @@ -30,6 +31,11 @@ def build_cursor_plan_details(*, entry: ModelPlanEntry) -> CursorPlanDetails | N relation.is_runtime_owned for relation in entry.cursor_input_relations ) and not (entry.start_cursor_override is not None and entry.end_cursor_override is not None) resolved_bounds: CursorBounds | None = entry.microbatch_range or entry.cursor_bounds + if resolved_bounds is not None and ( + isinstance(resolved_bounds.start, BoundSentinel) + or isinstance(resolved_bounds.end, BoundSentinel) + ): + resolved_bounds = None effective_grain: str | None = _effective_grain(entry=entry) effective_batch_size: str | None = _effective_batch_size( entry=entry, effective_grain=effective_grain diff --git a/src/sqlbuild/cli/output/_helpers/plan_text.py b/src/sqlbuild/cli/output/_helpers/plan_text.py index bba68b6b80..4f80eaef92 100644 --- a/src/sqlbuild/cli/output/_helpers/plan_text.py +++ b/src/sqlbuild/cli/output/_helpers/plan_text.py @@ -965,7 +965,7 @@ def _format_detail_entry( lines = _append_cursor_detail( lines=lines, entry=entry, - show_range=entry.backfill.action != BackfillAction.FULL, + show_range=reason != PlanReason.FULL_REFRESH, ) lines = _append_policy_line(lines=lines, entry=entry) lines = _append_run_despite_unchanged_detail(lines=lines, entry=entry) diff --git a/src/sqlbuild/cli/output/models.py b/src/sqlbuild/cli/output/models.py index 54270dbdd8..2e895c203f 100644 --- a/src/sqlbuild/cli/output/models.py +++ b/src/sqlbuild/cli/output/models.py @@ -57,6 +57,7 @@ AuditSeverity, ThresholdOperator, ) +from sqlbuild.compiler.compile.models import CompilerDiagnostic from sqlbuild.compiler.planner.models import CursorBounds from sqlbuild.runtime.observability.constants import ( RESOURCE_ATTEMPT_SKIPPED_EVENT, @@ -576,6 +577,7 @@ class WrittenTarget: audit_count: int test_count: int target_dir: Path + diagnostics: tuple[CompilerDiagnostic, ...] = field(default_factory=tuple) def summary_line(self) -> str: """Build a human-readable summary line.""" diff --git a/src/sqlbuild/cli/progress/classes/native_progress_projector.py b/src/sqlbuild/cli/progress/classes/native_progress_projector.py index 85c8ea0564..7a11db8c23 100644 --- a/src/sqlbuild/cli/progress/classes/native_progress_projector.py +++ b/src/sqlbuild/cli/progress/classes/native_progress_projector.py @@ -25,6 +25,7 @@ "external_manifest_discovery": "External manifest discovery", "project_discovery": "Project discovery", "project_compile": "Project compile", + "sql_test_planning": "SQL test planning", "dbt_command": "dbt command", "external_source_load": "loader callable", "ingestr_command": "ingestr subprocess", @@ -61,6 +62,7 @@ "managed_source_load", "project_compile", "project_discovery", + "sql_test_planning", "python_asset", "python_check", "python_hook", diff --git a/src/sqlbuild/compiler/manifest/_helpers/tests.py b/src/sqlbuild/compiler/manifest/_helpers/tests.py index 00a6d7ecd8..baa2ea9c83 100644 --- a/src/sqlbuild/compiler/manifest/_helpers/tests.py +++ b/src/sqlbuild/compiler/manifest/_helpers/tests.py @@ -96,6 +96,8 @@ def build_sql_test_nodes( compiled_parts: list[str] = [] step: ChainStep for step in test_entry.chain: + if not step.resolved_sql: + continue compiled_parts.append(f"-- step: {step.model_name}") compiled_parts.append(step.resolved_sql) compiled_code: str = "\n\n".join(compiled_parts) diff --git a/src/sqlbuild/compiler/planner/_helpers/graph/selectors.py b/src/sqlbuild/compiler/planner/_helpers/graph/selectors.py index fe2d7dbacb..a21235db77 100644 --- a/src/sqlbuild/compiler/planner/_helpers/graph/selectors.py +++ b/src/sqlbuild/compiler/planner/_helpers/graph/selectors.py @@ -20,6 +20,7 @@ SELECTOR_KIND_SEPARATOR, SELECTOR_MISSING_NAME_ERROR_FRAGMENT, SELECTOR_PATH_SEPARATOR, + SQL_FILE_SELECTOR_SUFFIX, ) from sqlbuild.compiler.planner.exceptions import PlannerInputError from sqlbuild.compiler.planner.main.selection.selector_expansion import split_selector_expansion @@ -344,7 +345,15 @@ def _resolve_path( if _path_matches(indexed_folder=indexed_folder, selector_folder=selector_folder) ) if not matched_keys: - raise PlannerInputError(f"no models found under path '{folder}'.", code="S009") + raise PlannerInputError( + f"no models found under path '{folder}'." + + ( + " Path selectors match folders; select a single model by its name." + if folder.endswith(SQL_FILE_SELECTOR_SUFFIX) + else "" + ), + code="S009", + ) result: set[CompiledObjectKey] = set(matched_keys) key: CompiledObjectKey diff --git a/src/sqlbuild/compiler/planner/_helpers/output/plan_entry.py b/src/sqlbuild/compiler/planner/_helpers/output/plan_entry.py index c73d9649b3..84778d33f8 100644 --- a/src/sqlbuild/compiler/planner/_helpers/output/plan_entry.py +++ b/src/sqlbuild/compiler/planner/_helpers/output/plan_entry.py @@ -47,6 +47,7 @@ compute_cursor_bounds, normalize_cursor_snapshot_grain, resolve_effective_timestamp_grain, + without_destination_cursor, ) from sqlbuild.compiler.planner._helpers.resolve.cursor_policies import ( resolve_future_cursor_config, @@ -66,6 +67,9 @@ ) from sqlbuild.compiler.planner.exceptions import PlannerInputError from sqlbuild.compiler.planner.main.changes._model_changes import detect_model_changes +from sqlbuild.compiler.planner.main.changes.relation_replacement import ( + replaces_incremental_relation, +) from sqlbuild.compiler.planner.main.execution.effective_microbatch_batch_size import ( resolve_effective_microbatch_batch_size, ) @@ -157,6 +161,7 @@ class _MicrobatchRangeInputs: start_cursor_config: StartCursorsConfig | None invocation_time: datetime | None full_refresh: bool + replaces_relation: bool = False @dataclass(frozen=True) @@ -649,6 +654,18 @@ def plan_model_from_change( source_warehouse_columns=context.source_warehouse_columns, ) + action: PlanAction + reason: PlanReason + action, reason = resolve_model_plan_action( + model=model, + change_result=change_result, + full_refresh=full_refresh, + ) + materialization_type: MaterializationType = get_materialization_type(model) + replaces_relation: bool = replaces_incremental_relation( + materialization_type=materialization_type, action=action + ) + resolved_sql: str = resolve_model_sql( adapter=adapter, model=model, @@ -659,14 +676,7 @@ def plan_model_from_change( cursor_overrides=cursor_overrides, suppress_runtime_cursor_bounds=suppress_runtime_cursor_bounds, external_sql_reference_resolver=external_sql_reference_resolver, - ) - - action: PlanAction - reason: PlanReason - action, reason = resolve_model_plan_action( - model=model, - change_result=change_result, - full_refresh=full_refresh, + replaces_relation=replaces_relation, ) on_schema_change: OnSchemaChange | None = _get_on_schema_change(model) @@ -685,8 +695,6 @@ def plan_model_from_change( unique_key: tuple[str, ...] = _get_unique_key(model) warehouse_columns: tuple[ColumnInfo, ...] = snapshot.existing_columns.get(model.name, ()) - materialization_type: MaterializationType = get_materialization_type(model) - warnings: tuple[PlanWarning, ...] = build_model_warnings( model_name=model.name, materialization_type=materialization_type, @@ -767,6 +775,7 @@ def plan_model_from_change( start_cursor_config=effective_start_cursor_config, invocation_time=context.invocation_time, full_refresh=full_refresh, + replaces_relation=replaces_relation, ), ) @@ -1241,6 +1250,8 @@ def _compute_microbatch_range( cursor_snapshot: ModelCursorSnapshot | None = inputs.snapshot.cursor_snapshots.get(model.name) if cursor_snapshot is None: return None + if inputs.replaces_relation and not inputs.full_refresh: + cursor_snapshot = without_destination_cursor(cursor_snapshot=cursor_snapshot) cursor_type: str | None = get_config_str(values=model.config.values, key="cursor_type") downstream_grain: str | None = get_config_str(values=model.config.values, key="cursor_grain") @@ -1366,6 +1377,8 @@ def _compute_plan_cursor_bounds( cursor_snapshot: ModelCursorSnapshot | None = snapshot.cursor_snapshots.get(model.name) if cursor_snapshot is None: return None + if backfill.action == BackfillAction.FULL: + cursor_snapshot = without_destination_cursor(cursor_snapshot=cursor_snapshot) lookback: str | None = get_config_str(values=model.config.values, key="lookback") cursor_start: str | None = get_config_cursor_bound( diff --git a/src/sqlbuild/compiler/planner/_helpers/output/plan_output.py b/src/sqlbuild/compiler/planner/_helpers/output/plan_output.py index c6eeb4a366..60608ac5e6 100644 --- a/src/sqlbuild/compiler/planner/_helpers/output/plan_output.py +++ b/src/sqlbuild/compiler/planner/_helpers/output/plan_output.py @@ -33,13 +33,9 @@ scope_overlaps, ) from sqlbuild.compiler.planner._helpers.resolve.resolve import resolve_function_sql -from sqlbuild.compiler.planner._helpers.sql_tests.assembly import ( - build_sql_test_planning_context, - plan_test, -) +from sqlbuild.compiler.planner._helpers.sql_tests.assembly import plan_sql_tests from sqlbuild.compiler.planner.exceptions import ( PlannerInputError, - SqlTestFixtureValidationError, ) from sqlbuild.compiler.planner.models import ( AuditPlanEntry, @@ -60,7 +56,7 @@ SeedPlanEntry, SourceLoadPlanEntry, SqlTestPlanEntry, - SqlTestPlanningContext, + SqlTestPlanResult, WarehouseSnapshot, ) from sqlbuild.compiler.planner.types import PlanReason @@ -478,36 +474,23 @@ def build_selected_test_entries( selected_tests.append(sql_test) if not selected_tests: return entries, warnings - test_planning_context: SqlTestPlanningContext = build_sql_test_planning_context( - project=project, - tests=tuple(selected_tests), - ) fixture_planning_context: RelationFixturePlanningContext | None = ( - build_relation_fixture_context( - project=project, - models_by_name=test_planning_context.models_by_name, - ) - if project.settings.sql_analysis - else None + build_relation_fixture_context(project=project) if project.settings.sql_analysis else None ) - for sql_test in selected_tests: - test_entry: SqlTestPlanEntry - test_warnings: tuple[PlanWarning, ...] - try: - test_entry, test_warnings = plan_test( - test=sql_test, - project=project, - adapter=adapter, - sql_analysis_enabled=project.settings.sql_analysis, - validate_fixtures=True, - fixture_planning_context=fixture_planning_context, - planning_context=test_planning_context, - ) - except SqlTestFixtureValidationError as error: - fixture_diagnostics.extend(error.diagnostics) + result: SqlTestPlanResult + for result in plan_sql_tests( + tests=tuple(selected_tests), + project=project, + adapter=adapter, + sql_analysis_enabled=project.settings.sql_analysis, + validate_fixtures=True, + fixture_planning_context=fixture_planning_context, + ): + if result.entry is None: + fixture_diagnostics.extend(result.fixture_diagnostics) continue - entries.append(test_entry) - warnings.extend(test_warnings) + entries.append(result.entry) + warnings.extend(result.warnings) if fixture_diagnostics: raise PlannerInputError("Invalid SQL test fixtures:\n- " + "\n- ".join(fixture_diagnostics)) return entries, warnings diff --git a/src/sqlbuild/compiler/planner/_helpers/resolve/cursor.py b/src/sqlbuild/compiler/planner/_helpers/resolve/cursor.py index b5d211e87c..03a0be528d 100644 --- a/src/sqlbuild/compiler/planner/_helpers/resolve/cursor.py +++ b/src/sqlbuild/compiler/planner/_helpers/resolve/cursor.py @@ -2,6 +2,7 @@ from __future__ import annotations +from dataclasses import replace from datetime import datetime from sqlbuild.compiler.planner.models import ( @@ -100,6 +101,17 @@ def compute_cursor_bounds( ) +def without_destination_cursor(*, cursor_snapshot: ModelCursorSnapshot) -> ModelCursorSnapshot: + """Return the snapshot as a first run sees it, ignoring the target's existing cursor.""" + + return replace( + cursor_snapshot, + target_max=None, + physical_target_max=None, + target_eligible_max=None, + ) + + def resolve_effective_timestamp_grain( *, cursor_type: str | None, diff --git a/src/sqlbuild/compiler/planner/_helpers/resolve/refs.py b/src/sqlbuild/compiler/planner/_helpers/resolve/refs.py index b130c5c3eb..0d00938244 100644 --- a/src/sqlbuild/compiler/planner/_helpers/resolve/refs.py +++ b/src/sqlbuild/compiler/planner/_helpers/resolve/refs.py @@ -255,44 +255,6 @@ def resolve_table_function_references( return "".join(parts) -def resolve_table_function_fixture_references( - *, query_sql: str, fixtures: dict[str, str] -) -> tuple[str, frozenset[str]]: - """Replace complete table-function invocations with model-test fixture relations.""" - - if not fixtures or _TABLE_FUNCTION_PATTERN.search(query_sql) is None: - return query_sql, frozenset() - parts: list[str] = [] - last_index: int = 0 - reached: set[str] = set() - match: re.Match[str] - for match in _iter_executable_matches(sql=query_sql, pattern=_TABLE_FUNCTION_PATTERN): - if match.start() < last_index: - continue - function_name: str = match.group(1) - fixture_sql: str | None = fixtures.get(function_name) - if fixture_sql is None: - continue - call_suffix_start: int = _skip_whitespace(sql=query_sql, start=match.end()) - if ( - call_suffix_start >= len(query_sql) - or query_sql[call_suffix_start] != SQL_FUNCTION_CALL_OPEN_PAREN - ): - continue - call_suffix_end: int = find_matching_paren( - sql=query_sql, - open_paren_index=call_suffix_start, - context="SQL table function fixture call", - ) - parts.append(query_sql[last_index : match.start()]) - parts.append(fixture_sql) - last_index = call_suffix_end + 1 - reached.add(function_name) - - parts.append(query_sql[last_index:]) - return "".join(parts), frozenset(reached) - - def _iter_executable_matches(*, sql: str, pattern: re.Pattern[str]) -> Iterator[re.Match[str]]: index: int = 0 while index < len(sql): diff --git a/src/sqlbuild/compiler/planner/_helpers/resolve/resolve.py b/src/sqlbuild/compiler/planner/_helpers/resolve/resolve.py index 6094d8af1c..ce01950cec 100644 --- a/src/sqlbuild/compiler/planner/_helpers/resolve/resolve.py +++ b/src/sqlbuild/compiler/planner/_helpers/resolve/resolve.py @@ -20,7 +20,10 @@ from sqlbuild.compiler.planner._helpers.resolve.config import ( get_config_append_cursor_inclusive, ) -from sqlbuild.compiler.planner._helpers.resolve.cursor import compute_cursor_bounds +from sqlbuild.compiler.planner._helpers.resolve.cursor import ( + compute_cursor_bounds, + without_destination_cursor, +) from sqlbuild.compiler.planner._helpers.resolve.cursor_policies import ( resolve_future_cursor_config, resolve_start_cursor_config, @@ -68,6 +71,7 @@ def resolve_model_sql( cursor_overrides: CursorOverridePair, suppress_runtime_cursor_bounds: bool = False, external_sql_reference_resolver: ExternalSqlReferenceResolver | None = None, + replaces_relation: bool = False, ) -> str: """Resolve all references in a model's query SQL to produce executable SQL.""" @@ -90,6 +94,7 @@ def resolve_model_sql( runtime_cursor_producer_names=context.runtime_cursor_producer_names, suppress_runtime_cursor_bounds=suppress_runtime_cursor_bounds, context=context, + replaces_relation=replaces_relation, ) cursor_roles: CursorInputRoles = resolve_cursor_input_roles(model=model) @@ -222,6 +227,7 @@ def _compute_model_cursor_bounds( runtime_cursor_producer_names: frozenset[str], suppress_runtime_cursor_bounds: bool, context: ModelPlanContext, + replaces_relation: bool = False, ) -> CursorBounds | None: """Compute cursor bounds for a model if it is incremental with a cursor.""" @@ -280,6 +286,8 @@ def _compute_model_cursor_bounds( return CursorBounds(start=BoundSentinel.START, end=BoundSentinel.END) if cursor_snapshot is None: return None + if replaces_relation: + cursor_snapshot = without_destination_cursor(cursor_snapshot=cursor_snapshot) lookback: str | None = get_config_str(values=model.config.values, key="lookback") cursor_start: str | None = get_config_cursor_bound( diff --git a/src/sqlbuild/compiler/planner/_helpers/sql_tests/analysis_assembly.py b/src/sqlbuild/compiler/planner/_helpers/sql_tests/analysis_assembly.py deleted file mode 100644 index 1929f9a448..0000000000 --- a/src/sqlbuild/compiler/planner/_helpers/sql_tests/analysis_assembly.py +++ /dev/null @@ -1,526 +0,0 @@ -"""Optional Polyglot-backed SQL-native test assembly helpers.""" - -from __future__ import annotations - -import logging -import re -from collections import OrderedDict -from dataclasses import dataclass -from functools import lru_cache -from typing import Any - -from sqlbuild.compiler.compile.constants import ( - DBT_REF_TEST_CTE_PREFIX, - REF_TEST_CTE_PREFIX, - SEED_TEST_CTE_PREFIX, - SOURCE_TEST_CTE_PREFIX, -) -from sqlbuild.compiler.compile.exceptions import CompileInputError -from sqlbuild.compiler.compile.models import CompileSqlTestCte -from sqlbuild.compiler.planner._helpers.scenario.relations import ( - _replace_relation_markers_in_polyglot_dict, -) -from sqlbuild.compiler.planner._helpers.sql_tests.comments import ( - replace_uncommented_pattern, -) -from sqlbuild.compiler.planner.models import SqlAnalysisResolvedTestSql, TestFunctionAnalysisContext -from sqlbuild.compiler.references.main._quoted_reference_call_pattern import ( - quoted_reference_call_pattern, -) -from sqlbuild.compiler.references.main.reference_call_prefix_pattern_text import ( - reference_call_prefix_pattern_text, -) -from sqlbuild.compiler.references.types import SqlReferenceKind -from sqlbuild.compiler.sql_analysis.constants import SQL_IDENTIFIER_PREFIX -from sqlbuild.compiler.sql_analysis.main.import_polyglot_sql import import_polyglot_sql -from sqlbuild.diagnostics.main.log_debug_event import log_debug_event - -_DEBUG_LOGGER: logging.Logger = logging.getLogger("sqlbuild.planner") -_REF_PATTERN: re.Pattern[str] = quoted_reference_call_pattern(SqlReferenceKind.REF) -_SOURCE_PATTERN: re.Pattern[str] = quoted_reference_call_pattern(SqlReferenceKind.SOURCE) -_SEED_PATTERN: re.Pattern[str] = quoted_reference_call_pattern(SqlReferenceKind.SEED) -_UDF_PATTERN: re.Pattern[str] = quoted_reference_call_pattern(SqlReferenceKind.UDF) -_TABLE_FUNCTION_PATTERN: re.Pattern[str] = quoted_reference_call_pattern( - SqlReferenceKind.TABLE_FUNCTION -) -_DBT_REF_PATTERN: re.Pattern[str] = re.compile( - rf'{reference_call_prefix_pattern_text(SqlReferenceKind.DBT_REF)}\s*"([^"]+)"\s*' - r'(?:,\s*"([^"]+)"\s*)?\)', - re.IGNORECASE, -) -_TRAILING_LINE_COMMENT_PATTERN: re.Pattern[str] = re.compile(r"--[^\n]*\Z") - - -@dataclass(frozen=True) -class _TestSqlAnalysisTemplate: - existing_cte_names: frozenset[str] - marker_calls: tuple[tuple[str, str], ...] - cte_body_sql: str - - -def build_test_function_analysis_context( - *, function_locations: dict[str, str] -) -> TestFunctionAnalysisContext: - return TestFunctionAnalysisContext( - locations=function_locations, - marker_targets=_function_marker_targets(function_locations=function_locations), - ) - - -class _TemplateMarkerTargetResolver: - def __init__(self, targets: tuple[tuple[str, str, str], ...]) -> None: - self._targets: dict[tuple[str, str], str] = { - (function_name, referenced_name): target - for function_name, referenced_name, target in targets - } - self.calls: list[tuple[str, str]] = [] - - def __call__(self, *, function_name: str, referenced_name: str) -> str | None: - self.calls.append((function_name, referenced_name)) - return self._targets.get((function_name, referenced_name)) - - def target(self, *, function_name: str, referenced_name: str) -> str | None: - return self._targets.get((function_name, referenced_name)) - - -class _TestAssemblyState: - def __init__(self, names: set[str]) -> None: - self.names = names - self.ctes: OrderedDict[str, str] = OrderedDict() - self.reached: set[str] = set() - - def add_name(self, name: str) -> None: - self.names.add(name) - - def add_reached(self, name: str) -> None: - self.reached.add(name) - - def setdefault_cte(self, *, name: str, sql: str) -> None: - self.ctes.setdefault(name, sql) - - -class _TestMarkerResolver: - def __init__( - self, - *, - state: _TestAssemblyState, - mock_refs: dict[str, str], - mock_sources: dict[str, str], - mock_seeds: dict[str, str], - mock_dbt_refs: dict[str, str], - function_locations: dict[str, str], - helper_ctes: tuple[CompileSqlTestCte, ...], - resolved_chain: dict[str, SqlAnalysisResolvedTestSql], - file_label: str, - ) -> None: - self.state = state - self.mock_refs = mock_refs - self.mock_sources = mock_sources - self.mock_seeds = mock_seeds - self.mock_dbt_refs = mock_dbt_refs - self.function_locations = function_locations - self.helper_ctes = helper_ctes - self.resolved_chain = resolved_chain - self.file_label = file_label - - def __call__(self, *, function_name: str, referenced_name: str) -> str | None: - if function_name == SqlReferenceKind.REF.function_name: - resolved_target: str | None = self._resolved_ref_target(referenced_name=referenced_name) - if resolved_target is not None: - return resolved_target - return self._mock_target( - generated_name=f"{REF_TEST_CTE_PREFIX}{referenced_name}", - referenced_name=referenced_name, - referenced_kind="ref", - mocks=self.mock_refs, - ) - if function_name == SqlReferenceKind.SOURCE.function_name: - return self._mock_target( - generated_name=f"{SOURCE_TEST_CTE_PREFIX}{referenced_name}", - referenced_name=referenced_name, - referenced_kind="source", - mocks=self.mock_sources, - ) - if function_name == SqlReferenceKind.SEED.function_name: - return self._mock_target( - generated_name=f"{SEED_TEST_CTE_PREFIX}{referenced_name}", - referenced_name=referenced_name, - referenced_kind="seed", - mocks=self.mock_seeds, - ) - if function_name == SqlReferenceKind.DBT_REF.function_name: - return self._mock_target( - generated_name=f"{DBT_REF_TEST_CTE_PREFIX}{referenced_name}", - referenced_name=referenced_name, - referenced_kind="dbt_ref", - mocks=self.mock_dbt_refs, - ) - if function_name in { - SqlReferenceKind.UDF.function_name, - SqlReferenceKind.TABLE_FUNCTION.function_name, - }: - return self.function_locations.get(referenced_name) - return None - - def _resolved_ref_target(self, *, referenced_name: str) -> str | None: - chain_sql: SqlAnalysisResolvedTestSql | None = self.resolved_chain.get(referenced_name) - if chain_sql is None: - return None - generated_name: str = f"{REF_TEST_CTE_PREFIX}{referenced_name}" - self._ensure_available( - generated_name=generated_name, - referenced_name=referenced_name, - referenced_kind="ref", - ) - dependency_name: str - dependency_sql: str - for dependency_name, dependency_sql in chain_sql.generated_ctes.items(): - self.state.setdefault_cte(name=dependency_name, sql=dependency_sql) - self.state.add_name(dependency_name) - self.state.setdefault_cte(name=generated_name, sql=chain_sql.cte_body_sql) - return generated_name - - def _mock_target( - self, - *, - generated_name: str, - referenced_name: str, - referenced_kind: str, - mocks: dict[str, str], - ) -> str | None: - mock_body: str | None = mocks.get(referenced_name) - if mock_body is None: - return None - self.state.add_reached(referenced_name) - self._ensure_available( - generated_name=generated_name, - referenced_name=referenced_name, - referenced_kind=referenced_kind, - ) - self.state.setdefault_cte( - name=generated_name, sql=self._wrap_mock_body(mock_body=mock_body) - ) - return generated_name - - def _ensure_available( - self, - *, - generated_name: str, - referenced_name: str, - referenced_kind: str, - ) -> None: - if generated_name not in self.state.names: - self.state.add_name(generated_name) - return - if generated_name in self.state.ctes: - return - raise CompileInputError( - f"SQL test '{self.file_label}' defines CTE '{generated_name}', which conflicts with " - "the " - f"generated {referenced_kind} CTE for '{referenced_name}'" - ) - - def _wrap_mock_body(self, *, mock_body: str) -> str: - if not self.helper_ctes: - return mock_body - helper_parts: list[str] = [] - helper_cte: CompileSqlTestCte - for helper_cte in self.helper_ctes: - helper_parts.append(_cte_definition_sql(name=helper_cte.name, sql=helper_cte.sql_body)) - return f"WITH {', '.join(helper_parts)} {mock_body}" - - -def try_resolve_test_model_sql_with_sql_analysis( - *, - query_sql: str, - mock_refs: dict[str, str], - mock_sources: dict[str, str], - mock_seeds: dict[str, str], - mock_dbt_refs: dict[str, str], - function_context: TestFunctionAnalysisContext, - helper_ctes: tuple[CompileSqlTestCte, ...], - resolved_chain: dict[str, SqlAnalysisResolvedTestSql], - file_label: str, - sql_analysis_dialect: str | None, -) -> SqlAnalysisResolvedTestSql | None: - """Return Polyglot-backed readable test SQL or None on parse failure.""" - - marker_targets: tuple[tuple[str, str, str], ...] = _test_marker_targets( - mock_refs=mock_refs, - mock_sources=mock_sources, - mock_seeds=mock_seeds, - mock_dbt_refs=mock_dbt_refs, - function_marker_targets=function_context.marker_targets, - resolved_chain=resolved_chain, - ) - template: _TestSqlAnalysisTemplate | None = _analyze_test_query_template( - query_sql=query_sql, - marker_targets=marker_targets, - sql_analysis_dialect=sql_analysis_dialect, - ) - if template is None: - return None - state: _TestAssemblyState = _TestAssemblyState(set(template.existing_cte_names)) - target_for_marker: _TestMarkerResolver = _TestMarkerResolver( - state=state, - mock_refs=mock_refs, - mock_sources=mock_sources, - mock_seeds=mock_seeds, - mock_dbt_refs=mock_dbt_refs, - function_locations=function_context.locations, - helper_ctes=helper_ctes, - resolved_chain=resolved_chain, - file_label=file_label, - ) - for function_name, referenced_name in template.marker_calls: - _ = target_for_marker( - function_name=function_name, - referenced_name=referenced_name, - ) - return _assemble_resolved_test_sql( - cte_body_sql=template.cte_body_sql, - generated_ctes=state.ctes, - reachable_mocks=state.reached, - ) - - -def _test_marker_targets( - *, - mock_refs: dict[str, str], - mock_sources: dict[str, str], - mock_seeds: dict[str, str], - mock_dbt_refs: dict[str, str], - function_marker_targets: tuple[tuple[str, str, str], ...], - resolved_chain: dict[str, SqlAnalysisResolvedTestSql], -) -> tuple[tuple[str, str, str], ...]: - targets: list[tuple[str, str, str]] = [] - targets.extend( - (SqlReferenceKind.REF.function_name, name, f"{REF_TEST_CTE_PREFIX}{name}") - for name in set(mock_refs) | set(resolved_chain) - ) - targets.extend( - (SqlReferenceKind.SOURCE.function_name, name, f"{SOURCE_TEST_CTE_PREFIX}{name}") - for name in mock_sources - ) - targets.extend( - (SqlReferenceKind.SEED.function_name, name, f"{SEED_TEST_CTE_PREFIX}{name}") - for name in mock_seeds - ) - targets.extend( - (SqlReferenceKind.DBT_REF.function_name, name, f"{DBT_REF_TEST_CTE_PREFIX}{name}") - for name in mock_dbt_refs - ) - targets.extend(function_marker_targets) - return tuple(sorted(targets)) - - -def _function_marker_targets( - *, function_locations: dict[str, str] -) -> tuple[tuple[str, str, str], ...]: - targets: list[tuple[str, str, str]] = [] - for name, location in function_locations.items(): - targets.append((SqlReferenceKind.UDF.function_name, name, location)) - targets.append((SqlReferenceKind.TABLE_FUNCTION.function_name, name, location)) - return tuple(targets) - - -@lru_cache(maxsize=4096) -def _analyze_test_query_template( - *, - query_sql: str, - marker_targets: tuple[tuple[str, str, str], ...], - sql_analysis_dialect: str | None, -) -> _TestSqlAnalysisTemplate | None: - polyglot_module: Any = import_polyglot_sql() - parsed_dict: dict[str, Any] | None = _try_parse_test_query( - polyglot_module=polyglot_module, - query_sql=query_sql, - sql_analysis_dialect=sql_analysis_dialect, - ) - if parsed_dict is None: - return None - target_for_marker: _TemplateMarkerTargetResolver = _TemplateMarkerTargetResolver(marker_targets) - _replace_relation_markers_in_polyglot_dict( - node=parsed_dict, - polyglot_module=polyglot_module, - sql_analysis_dialect=sql_analysis_dialect, - target_for_marker=target_for_marker, - ) - cte_body_sql: str = _replace_test_query_markers( - query_sql=query_sql, - resolver=target_for_marker, - ) - return _TestSqlAnalysisTemplate( - existing_cte_names=frozenset(_collect_existing_cte_names(parsed_dict)), - marker_calls=tuple(target_for_marker.calls), - cte_body_sql=cte_body_sql, - ) - - -def _replace_test_query_markers(*, query_sql: str, resolver: _TemplateMarkerTargetResolver) -> str: - reached: frozenset[tuple[str, str]] = frozenset(resolver.calls) - - def replace_one( - *, match: re.Match[str], reference_kind: SqlReferenceKind, referenced_name: str - ) -> str: - key: tuple[str, str] = (reference_kind.function_name, referenced_name) - if key not in reached: - return match.group(0) - target: str | None = resolver.target( - function_name=reference_kind.function_name, - referenced_name=referenced_name, - ) - return match.group(0) if target is None else target - - result: str = query_sql - for reference_kind, pattern in ( - (SqlReferenceKind.REF, _REF_PATTERN), - (SqlReferenceKind.SOURCE, _SOURCE_PATTERN), - (SqlReferenceKind.SEED, _SEED_PATTERN), - (SqlReferenceKind.UDF, _UDF_PATTERN), - (SqlReferenceKind.TABLE_FUNCTION, _TABLE_FUNCTION_PATTERN), - ): - result = replace_uncommented_pattern( - pattern=pattern, - replacement=lambda match, kind=reference_kind: replace_one( - match=match, - reference_kind=kind, - referenced_name=match.group(1), - ), - sql=result, - ) - - def replace_dbt_ref(match: re.Match[str]) -> str: - first_name: str = match.group(1) - second_name: str | None = match.group(2) - referenced_name: str = ( - f"{first_name}__{second_name}" if second_name is not None else first_name - ) - return replace_one( - match=match, - reference_kind=SqlReferenceKind.DBT_REF, - referenced_name=referenced_name, - ) - - return replace_uncommented_pattern( - pattern=_DBT_REF_PATTERN, - replacement=replace_dbt_ref, - sql=result, - ) - - -def _try_parse_test_query( - *, polyglot_module: Any, query_sql: str, sql_analysis_dialect: str | None -) -> dict[str, Any] | None: - try: - parsed: Any = polyglot_module.parse_one( - query_sql, dialect=sql_analysis_dialect or "generic" - ) - except polyglot_module.PolyglotError as error: - log_debug_event( - logger=_DEBUG_LOGGER, - message="sql test assembly parse failed; falling back", - sqlbuild_error=str(error), - ) - return None - return parsed.to_dict() - - -def _assemble_resolved_test_sql( - *, - cte_body_sql: str, - generated_ctes: OrderedDict[str, str], - reachable_mocks: set[str], -) -> SqlAnalysisResolvedTestSql | None: - if not generated_ctes: - return SqlAnalysisResolvedTestSql( - resolved_sql=cte_body_sql, - cte_body_sql=cte_body_sql, - generated_ctes=generated_ctes, - reachable_mock_names=frozenset(reachable_mocks), - ) - generated_cte_sql: str = ", ".join( - _cte_definition_sql(name=name, sql=sql) for name, sql in generated_ctes.items() - ) - leading_with_end: int | None = _leading_with_prefix_end(cte_body_sql) - resolved_sql: str = ( - f"{cte_body_sql[:leading_with_end]}{generated_cte_sql}, {cte_body_sql[leading_with_end:]}" - if leading_with_end is not None - else f"WITH {generated_cte_sql} {cte_body_sql}" - ) - return SqlAnalysisResolvedTestSql( - resolved_sql=resolved_sql, - cte_body_sql=cte_body_sql, - generated_ctes=generated_ctes, - reachable_mock_names=frozenset(reachable_mocks), - ) - - -def _leading_with_prefix_end(sql: str) -> int | None: - index: int = _skip_leading_ignorable(sql=sql, start=0) - with_end: int | None = _keyword_end(sql=sql, start=index, keyword="WITH") - if with_end is None: - return None - index = _skip_leading_ignorable(sql=sql, start=with_end) - recursive_end: int | None = _keyword_end(sql=sql, start=index, keyword="RECURSIVE") - if recursive_end is not None: - index = _skip_leading_ignorable(sql=sql, start=recursive_end) - return index - - -def _skip_leading_ignorable(*, sql: str, start: int) -> int: - index: int = start - while index < len(sql): - if sql[index].isspace(): - index += 1 - continue - if sql.startswith("--", index): - newline_index: int = sql.find("\n", index + 2) - index = len(sql) if newline_index == -1 else newline_index + 1 - continue - if sql.startswith("/*", index): - comment_end: int = sql.find("*/", index + 2) - if comment_end == -1: - return index - index = comment_end + 2 - continue - return index - return index - - -def _keyword_end(*, sql: str, start: int, keyword: str) -> int | None: - end: int = start + len(keyword) - if sql[start:end].upper() != keyword: - return None - if end < len(sql) and (sql[end].isalnum() or sql[end] == SQL_IDENTIFIER_PREFIX): - return None - return end - - -def _cte_definition_sql(*, name: str, sql: str) -> str: - body: str = sql.rstrip() - terminator: str = "\n" if _TRAILING_LINE_COMMENT_PATTERN.search(body) is not None else "" - return f"{name} AS ({body}{terminator})" - - -def _collect_existing_cte_names(parsed_dict: dict[str, Any]) -> set[str]: - generated_names: set[str] = set() - root_select: dict[str, Any] | None = _root_select(parsed_dict) - existing_with: dict[str, Any] | None = ( - root_select.get("with") if root_select is not None else None - ) - if isinstance(existing_with, dict): - cte: dict[str, Any] - for cte in existing_with.get("ctes", ()): - alias: Any | None = cte.get("alias") - if not isinstance(alias, dict): - continue - alias_name: Any | None = alias.get("name") - if alias_name is not None: - generated_names.add(str(alias_name)) - return generated_names - - -def _root_select(parsed_dict: dict[str, Any]) -> dict[str, Any] | None: - select_payload: Any | None = parsed_dict.get("select") - return select_payload if isinstance(select_payload, dict) else None diff --git a/src/sqlbuild/compiler/planner/_helpers/sql_tests/assembly.py b/src/sqlbuild/compiler/planner/_helpers/sql_tests/assembly.py index 2c11b9e111..4f01af717f 100644 --- a/src/sqlbuild/compiler/planner/_helpers/sql_tests/assembly.py +++ b/src/sqlbuild/compiler/planner/_helpers/sql_tests/assembly.py @@ -1,13 +1,11 @@ -"""Test chain resolution for SQL-native unit tests.""" +"""SQL-native unit-test plan entries built from native chain planning.""" from __future__ import annotations -import re -from dataclasses import dataclass, field +from dataclasses import replace from sqlbuild.adapter.contract.classes.base_adapter import BaseAdapter from sqlbuild.compiler.compile.constants import ( - ASSERT_TEST_CTE_PREFIX, DBT_REF_TEST_CTE_PREFIX, EXPECTED_TEST_CTE_PREFIX, REF_TEST_CTE_PREFIX, @@ -21,578 +19,268 @@ CompiledModelSqlTestPayload, CompiledObjectKey, CompiledProject, - CompiledRelationLocation, CompiledSqlTest, CompileSqlTestCte, ) from sqlbuild.compiler.compile.types import CompiledResourceType, SqlTestMode -from sqlbuild.compiler.planner._helpers.resolve.refs import ( - resolve_table_function_fixture_references, - resolve_table_function_references, - resolve_udf_references, -) -from sqlbuild.compiler.planner._helpers.sql_tests.analysis_assembly import ( - TestFunctionAnalysisContext, - build_test_function_analysis_context, - try_resolve_test_model_sql_with_sql_analysis, -) -from sqlbuild.compiler.planner._helpers.sql_tests.comments import ( - replace_uncommented_pattern, - uncommented_matches_by_pattern, - uncommented_pattern_matches, -) +from sqlbuild.compiler.planner._helpers.fixtures.completion import build_relation_fixture_context from sqlbuild.compiler.planner._helpers.sql_tests.fixture_validation import ( build_validated_test_fixtures, + fixture_location, ) -from sqlbuild.compiler.planner.exceptions import PlannerInputError +from sqlbuild.compiler.planner._helpers.sql_tests.native_planning import ( + plan_sql_tests_natively, + resolve_sql_test_model_chains, + sql_test_plan_error_messages, +) +from sqlbuild.compiler.planner.exceptions import SqlTestFixtureValidationError from sqlbuild.compiler.planner.models import ( - ChainStep, - PlanWarning, + NativeSqlTestPlan, RelationFixturePlanningContext, - SqlAnalysisResolvedTestSql, - SqlTestAssertionStep, SqlTestPlanEntry, - SqlTestPlanningContext, -) -from sqlbuild.compiler.planner.types import WarningSeverity -from sqlbuild.compiler.references.main._quoted_reference_call_pattern import ( - quoted_reference_call_pattern, + SqlTestPlanResult, ) -from sqlbuild.compiler.references.main.reference_call_prefix_pattern_text import ( - reference_call_prefix_pattern_text, +from sqlbuild.executor.testing.main.missing_expected_columns import ( + describe_missing_expected_columns, ) -from sqlbuild.compiler.references.types import SqlReferenceKind -_REF_PATTERN: re.Pattern[str] = re.compile( - quoted_reference_call_pattern(SqlReferenceKind.REF).pattern, re.IGNORECASE +_FUNCTION_RESOURCE_TYPES: frozenset[CompiledResourceType] = frozenset( + {CompiledResourceType.UDF, CompiledResourceType.TABLE_FN} ) -_SOURCE_PATTERN: re.Pattern[str] = re.compile( - quoted_reference_call_pattern(SqlReferenceKind.SOURCE).pattern, re.IGNORECASE -) -_SEED_PATTERN: re.Pattern[str] = re.compile( - quoted_reference_call_pattern(SqlReferenceKind.SEED).pattern, re.IGNORECASE -) -_DBT_REF_PATTERN: re.Pattern[str] = re.compile( - rf'{reference_call_prefix_pattern_text(SqlReferenceKind.DBT_REF)}"([^"]+)"' - r'(?:,\s*"([^"]+)")?\)', - re.IGNORECASE, -) -_TABLE_FUNCTION_PATTERN: re.Pattern[str] = re.compile( - reference_call_prefix_pattern_text(SqlReferenceKind.TABLE_FUNCTION), re.IGNORECASE -) -_LEADING_WITH_PATTERN: re.Pattern[str] = re.compile(r"^\s*WITH\b", re.IGNORECASE) -_TRAILING_LINE_COMMENT_PATTERN: re.Pattern[str] = re.compile(r"--[^\n]*\Z") - - -@dataclass -class _TextualChainResolver: - model_names: tuple[str, ...] - model_map: dict[str, CompiledModel] - test: CompiledSqlTest - mock_refs: dict[str, str] - mock_sources: dict[str, str] - mock_seeds: dict[str, str] - mock_dbt_refs: dict[str, str] - mock_table_functions: dict[str, str] - helper_ctes: tuple[CompileSqlTestCte, ...] - function_locations: dict[str, CompiledRelationLocation] - adapter: BaseAdapter - resolved: dict[str, str] = field(default_factory=dict) - step_sql: dict[str, str] = field(default_factory=dict) - reachable_mocks: set[str] = field(default_factory=set) - - def ensure_through(self, count: int) -> None: - for model_name in self.model_names[:count]: - if model_name in self.resolved: - continue - model: CompiledModel | None = self.model_map.get(model_name) - if model is None: - continue - sql: str - reached: frozenset[str] - sql, reached = _resolve_test_model_sql( - query_sql=_resolve_test_model_query_sql(model=model, test=self.test), - mock_refs=self.mock_refs, - mock_sources=self.mock_sources, - mock_seeds=self.mock_seeds, - mock_dbt_refs=self.mock_dbt_refs, - mock_table_functions=self.mock_table_functions, - helper_ctes=self.helper_ctes, - resolved_chain=self.resolved, - function_locations=self.function_locations, - adapter=self.adapter, - ) - self.reachable_mocks.update(reached) - self.step_sql[model_name] = sql - self.resolved[model_name] = f"({sql})" - - def resolve_all(self) -> dict[str, str]: - self.ensure_through(len(self.model_names)) - return self.resolved - - -def build_sql_test_planning_context( - *, project: CompiledProject, tests: tuple[CompiledSqlTest, ...] = () -) -> SqlTestPlanningContext: - """Build project-wide indexes shared by every SQL test in one plan.""" - - models_by_name: dict[str, CompiledModel] = {model.name: model for model in project.models} - model_dependencies: dict[str, frozenset[str]] = _build_model_dependencies( - model_map=models_by_name - ) - function_locations: dict[str, CompiledRelationLocation] = { - function.name: function.destination for function in project.functions - } - chain_names_by_topology: dict[ - tuple[tuple[str, ...], tuple[tuple[str, str], ...], frozenset[str]], - tuple[str, ...], - ] = {} - chain_names_by_test_key: dict[CompiledObjectKey, tuple[str, ...]] = {} - test: CompiledSqlTest - for test in tests: - topology_inputs: tuple[tuple[str, ...], dict[str, str], frozenset[str]] | None = ( - _test_model_chain_topology_inputs(test=test) - ) - if topology_inputs is None: - continue - expected_names, model_query_overrides, mock_ref_names = topology_inputs - topology_key: tuple[tuple[str, ...], tuple[tuple[str, str], ...], frozenset[str]] = ( - tuple(sorted(set(expected_names))), - tuple(sorted(model_query_overrides.items())), - mock_ref_names, - ) - ordered_names: tuple[str, ...] | None = chain_names_by_topology.get(topology_key) - if ordered_names is None: - ordered_names = _topo_sort_model_chain( - expected_names=expected_names, - model_map=models_by_name, - model_dependencies=model_dependencies, - model_query_overrides=model_query_overrides, - mock_ref_names=mock_ref_names, - ) - chain_names_by_topology[topology_key] = ordered_names - chain_names_by_test_key[test.key] = ordered_names - qualified_function_locations: dict[str, str] = { - name: target.qualified_name - for name, target in function_locations.items() - if target.qualified_name is not None - } - return SqlTestPlanningContext( - models_by_name=models_by_name, - model_dependencies=model_dependencies, - function_locations=function_locations, - qualified_function_locations=qualified_function_locations, - function_analysis_context=build_test_function_analysis_context( - function_locations=qualified_function_locations - ), - chain_names_by_test_key=chain_names_by_test_key, - ) -def plan_test( +def plan_sql_tests( *, - test: CompiledSqlTest, + tests: tuple[CompiledSqlTest, ...], project: CompiledProject, adapter: BaseAdapter, sql_analysis_enabled: bool = False, validate_fixtures: bool = False, fixture_planning_context: RelationFixturePlanningContext | None = None, - planning_context: SqlTestPlanningContext | None = None, -) -> tuple[SqlTestPlanEntry, tuple[PlanWarning, ...]]: - """Build a test plan entry with chained resolution.""" - - context: SqlTestPlanningContext = planning_context or build_sql_test_planning_context( - project=project, - tests=(test,), - ) - if isinstance(test.payload, CompiledDirectLogicSqlTestPayload): - return ( - _plan_direct_logic_test( - test=test, - function_locations=context.function_locations, - function_deps=_direct_function_deps(test=test, project=project), - adapter=adapter, - sql_analysis_enabled=sql_analysis_enabled, - ), - (), - ) - - model_payload: CompiledModelSqlTestPayload = test.payload - - model_map: dict[str, CompiledModel] = context.models_by_name - function_locations: dict[str, CompiledRelationLocation] = context.function_locations - function_analysis_context: TestFunctionAnalysisContext = context.function_analysis_context - mock_refs: dict[str, str] = _extract_mock_refs(test) - mock_sources: dict[str, str] = _extract_mock_sources(test) - mock_seeds: dict[str, str] = _extract_mock_seeds(test) - mock_dbt_refs: dict[str, str] = _extract_mock_dbt_refs(test) - mock_table_functions: dict[str, str] = _extract_mock_table_functions(test) - helper_ctes: tuple[CompileSqlTestCte, ...] = _extract_helper_ctes(test) - expected_map: dict[str, str] = _extract_expected_ctes(test) - assertion_map: dict[str, str] = _extract_assertion_ctes(test) - assertion_target_names: tuple[str, ...] = _extract_assertion_ref_targets( - assertion_map=assertion_map - ) +) -> tuple[SqlTestPlanResult, ...]: + """Plan SQL tests in one native batch, reporting fixture diagnostics per test.""" - expected_names: tuple[str, ...] = tuple( - dict.fromkeys((*model_payload.expected_model_names, *assertion_target_names)) - ) - ordered_names: tuple[str, ...] | None = context.chain_names_by_test_key.get(test.key) - if ordered_names is None: - ordered_names = _topo_sort_model_chain( - expected_names=expected_names, - model_map=model_map, - model_dependencies=context.model_dependencies, - model_query_overrides=model_payload.model_query_overrides, - mock_ref_names=frozenset(mock_refs), - ) + planned_tests: tuple[CompiledSqlTest, ...] = tests + diagnostics_by_index: dict[int, tuple[str, ...]] = {} + validation_context: RelationFixturePlanningContext | None = None if sql_analysis_enabled and validate_fixtures: - mock_refs, mock_sources, mock_seeds, expected_map = build_validated_test_fixtures( - test=test, + validation_context = fixture_planning_context or build_relation_fixture_context( + project=project + ) + planned_tests, diagnostics_by_index = _validate_fixtures( + tests=tests, project=project, adapter=adapter, - ordered_model_names=ordered_names, - mock_refs=mock_refs, - mock_sources=mock_sources, - mock_seeds=mock_seeds, - expected_outputs=expected_map, - planning_context=fixture_planning_context, + fixture_planning_context=validation_context, ) - - warnings: list[PlanWarning] = [] - reachable_mocks: set[str] = set() - sql_analysis_resolved: dict[str, SqlAnalysisResolvedTestSql] = {} - function_deps: list[CompiledObjectKey] = [] - textual_chain: _TextualChainResolver = _TextualChainResolver( - model_names=ordered_names, - model_map=model_map, - test=test, - mock_refs=mock_refs, - mock_sources=mock_sources, - mock_seeds=mock_seeds, - mock_dbt_refs=mock_dbt_refs, - mock_table_functions=mock_table_functions, - helper_ctes=helper_ctes, - function_locations=function_locations, + plannable_indexes: tuple[int, ...] = tuple( + index for index in range(len(tests)) if index not in diagnostics_by_index + ) + plans: tuple[NativeSqlTestPlan, ...] = plan_sql_tests_natively( + project=project, + tests=tuple(planned_tests[index] for index in plannable_indexes), adapter=adapter, + sql_analysis_enabled=sql_analysis_enabled, + render_sql=False, ) - - chain_steps: list[ChainStep] = [] - model_index: int - model_name: str - for model_index, model_name in enumerate(ordered_names): - model: CompiledModel | None = model_map.get(model_name) - if model is None: - warnings.append( - PlanWarning( - model_name=None, - severity=WarningSeverity.ERROR, - message=( - f"test '{test.name}' expects model '{model_name}' which does not exist" - ), - ) + plans_by_index: dict[int, NativeSqlTestPlan] = dict(zip(plannable_indexes, plans, strict=True)) + models_by_name: dict[str, CompiledModel] = {model.name: model for model in project.models} + results: list[SqlTestPlanResult] = [] + for index, test in enumerate(tests): + plan: NativeSqlTestPlan | None = plans_by_index.get(index) + if plan is None: + results.append( + SqlTestPlanResult(entry=None, fixture_diagnostics=diagnostics_by_index[index]) ) continue - function_deps.extend( - dep - for dep in model.deps - if dep.resource_type in {CompiledResourceType.UDF, CompiledResourceType.TABLE_FN} - and not ( - dep.resource_type == CompiledResourceType.TABLE_FN - and dep.name in mock_table_functions + plan_diagnostics: tuple[str, ...] = ( + *sql_test_plan_error_messages(warnings=plan.warnings), + *_expected_column_diagnostics( + test=test, plan=plan, fixture_planning_context=validation_context + ), + ) + if plan_diagnostics: + results.append(SqlTestPlanResult(entry=None, fixture_diagnostics=plan_diagnostics)) + continue + results.append( + SqlTestPlanResult( + entry=_plan_entry( + test=test, + plan=plan, + project=project, + models_by_name=models_by_name, + sql_analysis_enabled=sql_analysis_enabled, + ), + warnings=plan.warnings, ) ) + return tuple(results) - query_sql: str = _resolve_test_model_query_sql(model=model, test=test) - step_sql: str | None = None - step_lifted_ctes: tuple[tuple[str, str], ...] = () - comparison_body_sql: str | None = None - if sql_analysis_enabled: - analysis_query_sql, reached_table_functions = _resolve_table_function_fixtures( - query_sql=query_sql, - mock_table_functions=mock_table_functions, - helper_ctes=helper_ctes, - ) - reachable_mocks.update(reached_table_functions) - sql_analysis_sql: SqlAnalysisResolvedTestSql | None = ( - try_resolve_test_model_sql_with_sql_analysis( - query_sql=analysis_query_sql, - mock_refs=mock_refs, - mock_sources=mock_sources, - mock_seeds=mock_seeds, - mock_dbt_refs=mock_dbt_refs, - function_context=function_analysis_context, - helper_ctes=helper_ctes, - resolved_chain=sql_analysis_resolved, - file_label=str(test.test_file.relative_path), - sql_analysis_dialect=adapter.sql_analysis_dialect(), - ) - ) - if sql_analysis_sql is not None: - step_sql = sql_analysis_sql.resolved_sql - step_lifted_ctes = tuple(sql_analysis_sql.generated_ctes.items()) - comparison_body_sql = sql_analysis_sql.cte_body_sql - sql_analysis_resolved[model_name] = sql_analysis_sql - reachable_mocks.update(sql_analysis_sql.reachable_mock_names) - if step_sql is None: - textual_chain.ensure_through(model_index + 1) - step_sql = textual_chain.step_sql[model_name] - unresolved_warnings: tuple[PlanWarning, ...] = _validate_resolved_sql( - resolved_sql=step_sql, - test_name=test.name, - model_name=model_name, - ) - warnings.extend(unresolved_warnings) +def _expected_column_diagnostics( + *, + test: CompiledSqlTest, + plan: NativeSqlTestPlan, + fixture_planning_context: RelationFixturePlanningContext | None, +) -> tuple[str, ...]: + """Report expected columns that authoritative model output metadata proves absent.""" - expected_cte_sql: str = expected_map.get(model_name, "") - chain_steps.append( - ChainStep( - model_name=model_name, - resolved_sql=step_sql, - expected_cte_sql=expected_cte_sql or None, - lifted_ctes=step_lifted_ctes, - comparison_body_sql=comparison_body_sql, + if fixture_planning_context is None: + return () + diagnostics: list[str] = [] + for step in plan.chain: + available_columns: frozenset[str] | None = ( + fixture_planning_context.authoritative_columns.get( + (CompiledResourceType.MODEL, step.model_name) ) ) - - assertion_steps: tuple[SqlTestAssertionStep, ...] - assertion_reached_table_functions: frozenset[str] - assertion_steps, assertion_reached_table_functions = _build_assertion_steps( - assertion_map=assertion_map, - test=test, - function_locations=function_locations, - function_analysis_context=function_analysis_context, - helper_ctes=helper_ctes, - mock_table_functions=mock_table_functions, - textual_chain=textual_chain, - sql_analysis_resolved=sql_analysis_resolved, - adapter=adapter, - sql_analysis_enabled=sql_analysis_enabled, - ) - reachable_mocks.update(textual_chain.reachable_mocks) - reachable_mocks.update(assertion_reached_table_functions) - warnings.extend( - _unreachable_mock_warnings( - test_name=test.name, - reachable_mocks=reachable_mocks, - mock_refs=mock_refs, - mock_sources=mock_sources, - mock_seeds=mock_seeds, - mock_dbt_refs=mock_dbt_refs, - mock_table_functions=mock_table_functions, + if step.expected_columns is None or available_columns is None: + continue + message: str | None = describe_missing_expected_columns( + model_name=step.model_name, + expected_columns=step.expected_columns, + available_columns=available_columns, ) - ) - - entry: SqlTestPlanEntry = SqlTestPlanEntry( - key=test.key, - name=test.name, - source_path=test.source_path, - block_index=test.block_index, - parent_name=test.parent_name, - case_name=test.case_name, - case_index=test.case_index, - case_fingerprint=test.case_fingerprint, - parameter_schema=test.parameter_schema, - parameter_values=test.parameter_values, - mock_ref_names=tuple(sorted(mock_refs)), - mock_source_names=tuple(sorted(mock_sources)), - mock_seed_names=tuple(sorted(mock_seeds)), - mock_dbt_ref_names=tuple(sorted(mock_dbt_refs)), - mock_table_function_names=tuple(sorted(mock_table_functions)), - chain=tuple(chain_steps), - assertions=assertion_steps, - scope_deps=test.scope_deps, - function_deps=_dedupe_function_deps(function_deps), - sql_analysis_enabled=sql_analysis_enabled, - ) - return entry, tuple(warnings) + if message is None: + continue + location: str = fixture_location( + test=test, resource_type=CompiledResourceType.SQL_TEST, name=step.model_name + ) + diagnostics.append(f"SQL test '{test.name}': {location}: {message}") + return tuple(diagnostics) -def _unreachable_mock_warnings( +def _validate_fixtures( *, - test_name: str, - reachable_mocks: set[str], - mock_refs: dict[str, str], - mock_sources: dict[str, str], - mock_seeds: dict[str, str], - mock_dbt_refs: dict[str, str], - mock_table_functions: dict[str, str], -) -> tuple[PlanWarning, ...]: - warnings: list[PlanWarning] = [] - messages_by_mock: tuple[tuple[set[str], str, str], ...] = ( - ( - set(mock_refs), - "__ref__", - " is unreachable because a downstream model is also in the expected chain", - ), - (set(mock_sources), "__source__", " is unreachable"), - (set(mock_seeds), "__seed__", " is unreachable"), - (set(mock_dbt_refs), "__dbt_ref__", " is unreachable"), - (set(mock_table_functions), "__table_fn__", " is unreachable"), - ) - names: set[str] - prefix: str - suffix: str - for names, prefix, suffix in messages_by_mock: - for name in sorted(names - reachable_mocks): - warnings.append( - PlanWarning( - model_name=None, - severity=WarningSeverity.WARNING, - message=f"test '{test_name}' mock {prefix}{name}{suffix}", + tests: tuple[CompiledSqlTest, ...], + project: CompiledProject, + adapter: BaseAdapter, + fixture_planning_context: RelationFixturePlanningContext, +) -> tuple[tuple[CompiledSqlTest, ...], dict[int, tuple[str, ...]]]: + """Return tests with validated fixture completions plus diagnostics by test index.""" + + model_indexes: tuple[int, ...] = tuple( + index + for index, test in enumerate(tests) + if isinstance(test.payload, CompiledModelSqlTestPayload) + ) + if not model_indexes: + return tests, {} + chains: tuple[tuple[str, ...], ...] = resolve_sql_test_model_chains( + project=project, + tests=tuple(tests[index] for index in model_indexes), + ) + chains_by_index: dict[int, tuple[str, ...]] = dict(zip(model_indexes, chains, strict=True)) + context: RelationFixturePlanningContext = fixture_planning_context + validated_tests: list[CompiledSqlTest] = [] + diagnostics_by_index: dict[int, tuple[str, ...]] = {} + for index, test in enumerate(tests): + chain: tuple[str, ...] | None = chains_by_index.get(index) + if chain is None: + validated_tests.append(test) + continue + try: + validated_tests.append( + _with_validated_fixtures( + test=test, + project=project, + adapter=adapter, + ordered_model_names=chain, + fixture_planning_context=context, ) ) - return tuple(warnings) + except SqlTestFixtureValidationError as error: + validated_tests.append(test) + diagnostics_by_index[index] = error.diagnostics + return tuple(validated_tests), diagnostics_by_index -def resolve_test_model_chain_names( +def _with_validated_fixtures( *, test: CompiledSqlTest, project: CompiledProject, - model_map: dict[str, CompiledModel] | None = None, -) -> tuple[str, ...]: - """Return the unmocked model closure used to build one SQL test plan.""" - - if not isinstance(test.payload, CompiledModelSqlTestPayload): - return () - effective_model_map: dict[str, CompiledModel] = ( - {model.name: model for model in project.models} if model_map is None else model_map - ) - assertion_target_names: tuple[str, ...] = _extract_assertion_ref_targets( - assertion_map=_extract_assertion_ctes(test) - ) - expected_names: tuple[str, ...] = tuple( - dict.fromkeys((*test.payload.expected_model_names, *assertion_target_names)) + adapter: BaseAdapter, + ordered_model_names: tuple[str, ...], + fixture_planning_context: RelationFixturePlanningContext, +) -> CompiledSqlTest: + payload: CompiledModelSqlTestPayload | CompiledDirectLogicSqlTestPayload = test.payload + if not isinstance(payload, CompiledModelSqlTestPayload): + return test + mock_refs, mock_sources, mock_seeds, expected_outputs = build_validated_test_fixtures( + test=test, + project=project, + adapter=adapter, + ordered_model_names=ordered_model_names, + mock_refs=_prefixed_ctes(ctes=payload.authored_ctes, prefix=REF_TEST_CTE_PREFIX), + mock_sources=_prefixed_ctes(ctes=payload.authored_ctes, prefix=SOURCE_TEST_CTE_PREFIX), + mock_seeds=_prefixed_ctes(ctes=payload.authored_ctes, prefix=SEED_TEST_CTE_PREFIX), + expected_outputs=_expected_outputs(payload=payload), + planning_context=fixture_planning_context, + ) + completed_bodies: tuple[tuple[str, dict[str, str]], ...] = ( + (REF_TEST_CTE_PREFIX, mock_refs), + (SOURCE_TEST_CTE_PREFIX, mock_sources), + (SEED_TEST_CTE_PREFIX, mock_seeds), + ) + authored_ctes: tuple[CompileSqlTestCte, ...] = tuple( + _completed_cte(cte=cte, completed_bodies=completed_bodies) for cte in payload.authored_ctes + ) + expected_ctes: tuple[CompileSqlTestCte, ...] = tuple( + replace( + cte, + sql_body=expected_outputs.get( + cte.name.removeprefix(EXPECTED_TEST_CTE_PREFIX), cte.sql_body + ), + ) + for cte in payload.expected_ctes ) - return _topo_sort_model_chain( - expected_names=expected_names, - model_map=effective_model_map, - model_dependencies=_build_model_dependencies(model_map=effective_model_map), - model_query_overrides=test.payload.model_query_overrides, - mock_ref_names=frozenset(_extract_mock_refs(test)), + return replace( + test, + payload=replace(payload, authored_ctes=authored_ctes, expected_ctes=expected_ctes), ) -def _test_model_chain_topology_inputs( - *, test: CompiledSqlTest -) -> tuple[tuple[str, ...], dict[str, str], frozenset[str]] | None: - if not isinstance(test.payload, CompiledModelSqlTestPayload): - return None - assertion_target_names: tuple[str, ...] = _extract_assertion_ref_targets( - assertion_map=_extract_assertion_ctes(test) - ) - expected_names: tuple[str, ...] = tuple( - dict.fromkeys((*test.payload.expected_model_names, *assertion_target_names)) - ) - return ( - expected_names, - test.payload.model_query_overrides, - frozenset(_extract_mock_refs(test)), - ) +def _completed_cte( + *, + cte: CompileSqlTestCte, + completed_bodies: tuple[tuple[str, dict[str, str]], ...], +) -> CompileSqlTestCte: + for prefix, bodies in completed_bodies: + if cte.name.startswith(prefix): + return replace(cte, sql_body=bodies.get(cte.name.removeprefix(prefix), cte.sql_body)) + return cte + + +def _expected_outputs(*, payload: CompiledModelSqlTestPayload) -> dict[str, str]: + return { + cte.name.removeprefix(EXPECTED_TEST_CTE_PREFIX): cte.sql_body + for cte in payload.expected_ctes + } -def _build_assertion_steps( - *, - assertion_map: dict[str, str], - test: CompiledSqlTest, - function_locations: dict[str, CompiledRelationLocation], - function_analysis_context: TestFunctionAnalysisContext, - helper_ctes: tuple[CompileSqlTestCte, ...], - mock_table_functions: dict[str, str], - textual_chain: _TextualChainResolver, - sql_analysis_resolved: dict[str, SqlAnalysisResolvedTestSql], - adapter: BaseAdapter, - sql_analysis_enabled: bool, -) -> tuple[tuple[SqlTestAssertionStep, ...], frozenset[str]]: - mock_refs: dict[str, str] = _extract_mock_refs(test) - mock_sources: dict[str, str] = _extract_mock_sources(test) - mock_seeds: dict[str, str] = _extract_mock_seeds(test) - mock_dbt_refs: dict[str, str] = _extract_mock_dbt_refs(test) - assertion_steps: list[SqlTestAssertionStep] = [] - reached_table_functions: set[str] = set() - assertion_name: str - assertion_sql: str - for assertion_name, assertion_sql in assertion_map.items(): - fixture_resolved_assertion_sql, reached = _resolve_table_function_fixtures( - query_sql=assertion_sql, - mock_table_functions=mock_table_functions, - helper_ctes=helper_ctes, - ) - reached_table_functions.update(reached) - resolved_assertion_sql: str | None = None - assertion_lifted_ctes: tuple[tuple[str, str], ...] = () - assertion_comparison_body_sql: str | None = None - if sql_analysis_enabled: - analyzed_assertion_sql: SqlAnalysisResolvedTestSql | None = ( - try_resolve_test_model_sql_with_sql_analysis( - query_sql=fixture_resolved_assertion_sql, - mock_refs=mock_refs, - mock_sources=mock_sources, - mock_seeds=mock_seeds, - mock_dbt_refs=mock_dbt_refs, - function_context=function_analysis_context, - helper_ctes=helper_ctes, - resolved_chain=sql_analysis_resolved, - file_label=str(test.test_file.relative_path), - sql_analysis_dialect=adapter.sql_analysis_dialect(), - ) - ) - if analyzed_assertion_sql is not None and not _has_unresolved_test_reference( - analyzed_assertion_sql.resolved_sql - ): - resolved_assertion_sql = analyzed_assertion_sql.resolved_sql - assertion_lifted_ctes = tuple(analyzed_assertion_sql.generated_ctes.items()) - assertion_comparison_body_sql = analyzed_assertion_sql.cte_body_sql - if resolved_assertion_sql is None: - resolved_assertion_sql = _resolve_assertion_sql( - sql=fixture_resolved_assertion_sql, - resolved_chain=textual_chain.resolve_all(), - mock_refs=mock_refs, - mock_sources=mock_sources, - mock_seeds=mock_seeds, - mock_dbt_refs=mock_dbt_refs, - helper_ctes=helper_ctes, - function_locations=function_locations, - adapter=adapter, - ) - assertion_steps.append( - SqlTestAssertionStep( - name=assertion_name, - resolved_sql=resolved_assertion_sql, - lifted_ctes=assertion_lifted_ctes, - comparison_body_sql=assertion_comparison_body_sql, - ) - ) - return tuple(assertion_steps), frozenset(reached_table_functions) +def _prefixed_ctes(*, ctes: tuple[CompileSqlTestCte, ...], prefix: str) -> dict[str, str]: + return { + cte.name.removeprefix(prefix): cte.sql_body for cte in ctes if cte.name.startswith(prefix) + } -def _plan_direct_logic_test( +def _prefixed_names(*, test: CompiledSqlTest, prefix: str) -> tuple[str, ...]: + if not isinstance(test.payload, CompiledModelSqlTestPayload): + return () + return tuple(sorted(_prefixed_ctes(ctes=test.payload.authored_ctes, prefix=prefix))) + + +def _plan_entry( *, test: CompiledSqlTest, - function_locations: dict[str, CompiledRelationLocation], - function_deps: tuple[CompiledObjectKey, ...], - adapter: BaseAdapter, + plan: NativeSqlTestPlan, + project: CompiledProject, + models_by_name: dict[str, CompiledModel], sql_analysis_enabled: bool, ) -> SqlTestPlanEntry: - if not isinstance(test.payload, CompiledDirectLogicSqlTestPayload): - raise PlannerInputError(f"test '{test.name}' is not a direct-logic SQL test") - helper_ctes: tuple[CompileSqlTestCte, ...] = test.payload.helper_ctes - helper_with: str = _build_helper_with_clause(helper_ctes) - actual_sql: str = test.payload.actual_cte.sql_body - if test.payload.mode == SqlTestMode.UDF: - actual_sql = resolve_udf_references( - query_sql=actual_sql, - function_locations=function_locations, - adapter=adapter, - ) - if test.payload.mode == SqlTestMode.TABLE_FN: - actual_sql = resolve_table_function_references( - query_sql=actual_sql, - function_locations=function_locations, - adapter=adapter, - ) - label: str = test.payload.mode.value + mock_table_function_names: tuple[str, ...] = _prefixed_names( + test=test, prefix=TABLE_FN_TEST_CTE_PREFIX + ) return SqlTestPlanEntry( key=test.key, name=test.name, @@ -604,37 +292,58 @@ def _plan_direct_logic_test( case_fingerprint=test.case_fingerprint, parameter_schema=test.parameter_schema, parameter_values=test.parameter_values, - chain=( - ChainStep( - model_name=f"{label} {test.name}", - resolved_sql=_wrap_direct_logic_sql( - sql=actual_sql, - helper_with=helper_with, - ), - expected_cte_sql=_wrap_direct_logic_sql( - sql=test.payload.expected_cte.sql_body, - helper_with=helper_with, - ), - ), - ), + mock_ref_names=_prefixed_names(test=test, prefix=REF_TEST_CTE_PREFIX), + mock_source_names=_prefixed_names(test=test, prefix=SOURCE_TEST_CTE_PREFIX), + mock_seed_names=_prefixed_names(test=test, prefix=SEED_TEST_CTE_PREFIX), + mock_dbt_ref_names=_prefixed_names(test=test, prefix=DBT_REF_TEST_CTE_PREFIX), + mock_table_function_names=mock_table_function_names, + chain=plan.chain, + assertions=plan.assertions, scope_deps=test.scope_deps, - function_deps=function_deps, + function_deps=( + _direct_function_deps(test=test, project=project) + if isinstance(test.payload, CompiledDirectLogicSqlTestPayload) + else _chain_function_deps( + model_names=plan.model_names, + models_by_name=models_by_name, + mocked_table_functions=frozenset(mock_table_function_names), + ) + ), sql_analysis_enabled=sql_analysis_enabled, ) +def _chain_function_deps( + *, + model_names: tuple[str, ...], + models_by_name: dict[str, CompiledModel], + mocked_table_functions: frozenset[str], +) -> tuple[CompiledObjectKey, ...]: + deps: dict[CompiledObjectKey, None] = {} + for model_name in model_names: + model: CompiledModel | None = models_by_name.get(model_name) + if model is None: + continue + for dep in model.deps: + if dep.resource_type not in _FUNCTION_RESOURCE_TYPES: + continue + if dep.resource_type == CompiledResourceType.TABLE_FN and ( + dep.name in mocked_table_functions + ): + continue + deps[dep] = None + return tuple(deps) + + def _direct_function_deps( *, test: CompiledSqlTest, project: CompiledProject ) -> tuple[CompiledObjectKey, ...]: if not isinstance(test.payload, CompiledDirectLogicSqlTestPayload): return () - resource_type: CompiledResourceType | None = ( - CompiledResourceType.UDF - if test.payload.mode == SqlTestMode.UDF - else CompiledResourceType.TABLE_FN - if test.payload.mode == SqlTestMode.TABLE_FN - else None - ) + resource_type: CompiledResourceType | None = { + SqlTestMode.UDF: CompiledResourceType.UDF, + SqlTestMode.TABLE_FN: CompiledResourceType.TABLE_FN, + }.get(test.payload.mode) if resource_type is None: return () tested_names: frozenset[str] = frozenset(test.payload.tested_resource_names) @@ -643,572 +352,3 @@ def _direct_function_deps( for function in project.functions if function.key.resource_type == resource_type and function.name in tested_names ) - - -def _wrap_direct_logic_sql(*, sql: str, helper_with: str) -> str: - if helper_with: - return f"{helper_with} {sql}" - return sql - - -def _extract_assertion_ref_targets(*, assertion_map: dict[str, str]) -> tuple[str, ...]: - targets: list[str] = [] - sql: str - for sql in assertion_map.values(): - match: re.Match[str] - for match in uncommented_pattern_matches(pattern=_REF_PATTERN, sql=sql): - targets.append(match.group(1)) - return tuple(dict.fromkeys(targets)) - - -def _resolve_assertion_sql( - *, - sql: str, - resolved_chain: dict[str, str], - mock_refs: dict[str, str], - mock_sources: dict[str, str], - mock_seeds: dict[str, str], - mock_dbt_refs: dict[str, str], - helper_ctes: tuple[CompileSqlTestCte, ...], - function_locations: dict[str, CompiledRelationLocation], - adapter: BaseAdapter, -) -> str: - assertion_resolved_chain: dict[str, str] - assertion_chain_ctes: tuple[str, ...] - assertion_resolved_chain, assertion_chain_ctes = _build_assertion_chain_ctes( - assertion_sql=sql, - resolved_chain=resolved_chain, - requires_flat_ctes=adapter.requires_derived_table_aliases(), - ) - resolved_sql: str - resolved_sql, _ = _resolve_test_model_sql( - query_sql=sql, - mock_refs=mock_refs, - mock_sources=mock_sources, - mock_seeds=mock_seeds, - mock_dbt_refs=mock_dbt_refs, - mock_table_functions={}, - helper_ctes=helper_ctes, - resolved_chain=assertion_resolved_chain, - function_locations=function_locations, - adapter=adapter, - ) - if assertion_chain_ctes: - return f"WITH {', '.join(assertion_chain_ctes)} {resolved_sql}" - return resolved_sql - - -def _build_assertion_chain_ctes( - *, - assertion_sql: str, - resolved_chain: dict[str, str], - requires_flat_ctes: bool, -) -> tuple[dict[str, str], tuple[str, ...]]: - if requires_flat_ctes and _LEADING_WITH_PATTERN.search(assertion_sql) is not None: - raise PlannerInputError( - "SQL test assertion fallback cannot safely flatten an assertion beginning with WITH" - ) - assertion_resolved_chain: dict[str, str] = {} - cte_parts: list[str] = [] - seen_names: set[str] = set() - match: re.Match[str] - for match in uncommented_pattern_matches(pattern=_REF_PATTERN, sql=assertion_sql): - name: str = match.group(1) - if name in seen_names or name not in resolved_chain: - continue - seen_names.add(name) - cte_name: str = f"{REF_TEST_CTE_PREFIX}{name}" - resolved_sql: str = resolved_chain[name].strip() - if resolved_sql.startswith("(") and resolved_sql.endswith(")"): - resolved_sql = resolved_sql[1:-1].strip() - if requires_flat_ctes and _LEADING_WITH_PATTERN.search(resolved_sql) is not None: - raise PlannerInputError( - "SQL test assertion fallback cannot safely flatten a referenced model beginning " - "with WITH" - ) - assertion_resolved_chain[name] = cte_name - cte_parts.append(_cte_definition_sql(name=cte_name, sql=resolved_sql)) - return assertion_resolved_chain, tuple(cte_parts) - - -def _dedupe_function_deps(deps: list[CompiledObjectKey]) -> tuple[CompiledObjectKey, ...]: - deduped: list[CompiledObjectKey] = [] - seen: set[CompiledObjectKey] = set() - dep: CompiledObjectKey - for dep in deps: - if dep in seen: - continue - seen.add(dep) - deduped.append(dep) - return tuple(deduped) - - -def _resolve_test_model_query_sql( - *, - model: CompiledModel, - test: CompiledSqlTest, -) -> str: - """Resolve model SQL for one SQL test, applying test macro mocks if present.""" - - if not isinstance(test.payload, CompiledModelSqlTestPayload): - raise PlannerInputError(f"test '{test.name}' is not a model SQL test") - return test.payload.model_query_overrides.get(model.name, model.query_sql) - - -def _resolve_test_model_sql( - *, - query_sql: str, - mock_refs: dict[str, str], - mock_sources: dict[str, str], - mock_seeds: dict[str, str], - mock_dbt_refs: dict[str, str], - mock_table_functions: dict[str, str], - helper_ctes: tuple[CompileSqlTestCte, ...], - resolved_chain: dict[str, str], - function_locations: dict[str, CompiledRelationLocation], - adapter: BaseAdapter, -) -> tuple[str, frozenset[str]]: - """Replace refs and sources in model SQL and return it with reached mock names.""" - - ref_matches: tuple[re.Match[str], ...] - source_matches: tuple[re.Match[str], ...] - seed_matches: tuple[re.Match[str], ...] - dbt_ref_matches: tuple[re.Match[str], ...] - ref_matches, source_matches, seed_matches, dbt_ref_matches = uncommented_matches_by_pattern( - patterns=(_REF_PATTERN, _SOURCE_PATTERN, _SEED_PATTERN, _DBT_REF_PATTERN), - sql=query_sql, - ) - reachable_mocks: set[str] = { - match.group(1) - for match in ref_matches - if match.group(1) in mock_refs and match.group(1) not in resolved_chain - } - reachable_mocks.update( - match.group(1) for match in source_matches if match.group(1) in mock_sources - ) - reachable_mocks.update(match.group(1) for match in seed_matches if match.group(1) in mock_seeds) - reachable_mocks.update( - name - for match in dbt_ref_matches - if (name := _dbt_ref_fixture_name(package_name=match.group(1), model_name=match.group(2))) - in mock_dbt_refs - ) - helper_with: str = _build_helper_with_clause(helper_ctes) - - def _replace_ref(match: re.Match[str]) -> str: - ref_name: str = match.group(1) - if ref_name in resolved_chain: - return resolved_chain[ref_name] - if ref_name in mock_refs: - mock_body: str = mock_refs[ref_name] - return _wrap_mock_with_helpers( - mock_body=mock_body, - helper_with=helper_with, - ) - return match.group(0) - - def _replace_source(match: re.Match[str]) -> str: - source_name: str = match.group(1) - if source_name in mock_sources: - mock_body: str = mock_sources[source_name] - return _wrap_mock_with_helpers( - mock_body=mock_body, - helper_with=helper_with, - ) - return match.group(0) - - def _replace_seed(match: re.Match[str]) -> str: - seed_name: str = match.group(1) - if seed_name in mock_seeds: - mock_body: str = mock_seeds[seed_name] - return _wrap_mock_with_helpers( - mock_body=mock_body, - helper_with=helper_with, - ) - return match.group(0) - - def _replace_dbt_ref(match: re.Match[str]) -> str: - dbt_ref_name: str = _dbt_ref_fixture_name( - package_name=match.group(1), model_name=match.group(2) - ) - if dbt_ref_name in mock_dbt_refs: - mock_body: str = mock_dbt_refs[dbt_ref_name] - return _wrap_mock_with_helpers( - mock_body=mock_body, - helper_with=helper_with, - ) - return match.group(0) - - result: str = replace_uncommented_pattern( - pattern=_REF_PATTERN, replacement=_replace_ref, sql=query_sql - ) - result = replace_uncommented_pattern( - pattern=_SOURCE_PATTERN, replacement=_replace_source, sql=result - ) - result = replace_uncommented_pattern( - pattern=_SEED_PATTERN, replacement=_replace_seed, sql=result - ) - result = replace_uncommented_pattern( - pattern=_DBT_REF_PATTERN, replacement=_replace_dbt_ref, sql=result - ) - result, reached_table_functions = _resolve_table_function_fixtures( - query_sql=result, - mock_table_functions=mock_table_functions, - helper_ctes=helper_ctes, - ) - reachable_mocks.update(reached_table_functions) - result = resolve_udf_references( - query_sql=result, - function_locations=function_locations, - adapter=adapter, - ) - result = resolve_table_function_references( - query_sql=result, - function_locations=function_locations, - adapter=adapter, - ) - return result, frozenset(reachable_mocks) - - -def _validate_resolved_sql( - *, - resolved_sql: str, - test_name: str, - model_name: str, -) -> tuple[PlanWarning, ...]: - """Check for unresolved refs and sources in resolved test SQL.""" - - patterns: tuple[re.Pattern[str], ...] = ( - _REF_PATTERN, - _SOURCE_PATTERN, - _SEED_PATTERN, - _DBT_REF_PATTERN, - ) - if not any(pattern.search(resolved_sql) for pattern in patterns): - return () - warnings: list[PlanWarning] = [] - ref_matches: tuple[re.Match[str], ...] - source_matches: tuple[re.Match[str], ...] - seed_matches: tuple[re.Match[str], ...] - dbt_ref_matches: tuple[re.Match[str], ...] - ref_matches, source_matches, seed_matches, dbt_ref_matches = uncommented_matches_by_pattern( - patterns=patterns, - sql=resolved_sql, - ) - ref_match: re.Match[str] - for ref_match in ref_matches: - ref_name: str = ref_match.group(1) - warnings.append( - PlanWarning( - model_name=model_name, - severity=WarningSeverity.ERROR, - message=( - f"test '{test_name}': model '{model_name}'" - f" references {SqlReferenceKind.REF.example_call(ref_name)} which has" - f" no mock and is not in the expected chain" - ), - ) - ) - source_match: re.Match[str] - for source_match in source_matches: - source_name: str = source_match.group(1) - warnings.append( - PlanWarning( - model_name=model_name, - severity=WarningSeverity.ERROR, - message=( - f"test '{test_name}': model '{model_name}'" - f" references {SqlReferenceKind.SOURCE.example_call(source_name)} which" - f" has no mock" - ), - ) - ) - seed_match: re.Match[str] - for seed_match in seed_matches: - seed_name: str = seed_match.group(1) - warnings.append( - PlanWarning( - model_name=model_name, - severity=WarningSeverity.ERROR, - message=( - f"test '{test_name}': model '{model_name}'" - f" references {SqlReferenceKind.SEED.example_call(seed_name)} which" - f" has no mock" - ), - ) - ) - dbt_ref_match: re.Match[str] - for dbt_ref_match in dbt_ref_matches: - dbt_ref_name: str = _dbt_ref_fixture_name( - package_name=dbt_ref_match.group(1), model_name=dbt_ref_match.group(2) - ) - warnings.append( - PlanWarning( - model_name=model_name, - severity=WarningSeverity.ERROR, - message=( - f"test '{test_name}': model '{model_name}' references " - f"__dbt_ref__{dbt_ref_name} which has no mock" - ), - ) - ) - return tuple(warnings) - - -def _has_unresolved_test_reference(sql: str) -> bool: - reference_matches: tuple[tuple[re.Match[str], ...], ...] = uncommented_matches_by_pattern( - patterns=( - _REF_PATTERN, - _SOURCE_PATTERN, - _SEED_PATTERN, - _DBT_REF_PATTERN, - _TABLE_FUNCTION_PATTERN, - ), - sql=sql, - ) - return any(reference_matches) - - -def _wrap_mock_with_helpers( - *, - mock_body: str, - helper_with: str, -) -> str: - """Wrap mock CTE body with helper CTEs as a subquery.""" - - if helper_with: - return f"({helper_with} {mock_body})" - return f"({mock_body})" - - -def _resolve_table_function_fixtures( - *, - query_sql: str, - mock_table_functions: dict[str, str], - helper_ctes: tuple[CompileSqlTestCte, ...], -) -> tuple[str, frozenset[str]]: - helper_with: str = _build_helper_with_clause(helper_ctes) - fixtures: dict[str, str] = { - name: _wrap_mock_with_helpers(mock_body=body, helper_with=helper_with) - for name, body in mock_table_functions.items() - } - return resolve_table_function_fixture_references(query_sql=query_sql, fixtures=fixtures) - - -def _build_helper_with_clause( - helper_ctes: tuple[CompileSqlTestCte, ...], -) -> str: - """Build a WITH clause from helper CTEs.""" - - if not helper_ctes: - return "" - parts: list[str] = [] - cte: CompileSqlTestCte - for cte in helper_ctes: - parts.append(_cte_definition_sql(name=cte.name, sql=cte.sql_body)) - return "WITH " + ", ".join(parts) - - -def _cte_definition_sql(*, name: str, sql: str) -> str: - body: str = sql.rstrip() - terminator: str = "\n" if _TRAILING_LINE_COMMENT_PATTERN.search(body) is not None else "" - return f"{name} AS ({body}{terminator})" - - -def _topo_sort_model_chain( - *, - expected_names: tuple[str, ...], - model_map: dict[str, CompiledModel], - model_dependencies: dict[str, frozenset[str]], - model_query_overrides: dict[str, str], - mock_ref_names: frozenset[str], -) -> tuple[str, ...]: - """Sort asserted models and their unmocked model dependencies in execution order.""" - - ordered: list[str] = [] - visited: set[str] = set() - - def _visit(*, node: str, seen: set[str], result: list[str]) -> tuple[set[str], list[str]]: - if node in seen: - return seen, result - seen = seen | {node} - model: CompiledModel | None = model_map.get(node) - if model is not None: - dependency_names: set[str] = _test_model_dependency_names( - model=model, - query_override=model_query_overrides.get(node), - model_map=model_map, - model_dependencies=model_dependencies, - mock_ref_names=mock_ref_names, - ) - dependency_name: str - for dependency_name in sorted(dependency_names): - seen, result = _visit(node=dependency_name, seen=seen, result=result) - return seen, [*result, node] - - for name in sorted(expected_names): - visited, ordered = _visit(node=name, seen=visited, result=ordered) - - return tuple(ordered) - - -def _test_model_dependency_names( - *, - model: CompiledModel, - query_override: str | None, - model_map: dict[str, CompiledModel], - model_dependencies: dict[str, frozenset[str]], - mock_ref_names: frozenset[str], -) -> set[str]: - if query_override is not None: - candidates: set[str] = { - match.group(1) - for match in uncommented_pattern_matches(pattern=_REF_PATTERN, sql=query_override) - } - else: - candidates = set(model_dependencies.get(model.name, ())) - return {name for name in candidates if name not in mock_ref_names and name in model_map} - - -def _build_model_dependencies(*, model_map: dict[str, CompiledModel]) -> dict[str, frozenset[str]]: - dependencies_by_model: dict[str, frozenset[str]] = {} - model: CompiledModel - for model in model_map.values(): - dependencies_by_model[model.name] = frozenset( - dependency.name - for dependency in model.deps - if dependency.resource_type == CompiledResourceType.MODEL - and dependency.name in model_map - ) - return dependencies_by_model - - -def _extract_mock_refs(test: CompiledSqlTest) -> dict[str, str]: - """Extract mock ref CTE bodies keyed by model name.""" - - result: dict[str, str] = {} - if not isinstance(test.payload, CompiledModelSqlTestPayload): - return result - cte: CompileSqlTestCte - for cte in test.payload.authored_ctes: - if cte.name.startswith(REF_TEST_CTE_PREFIX): - name: str = cte.name.removeprefix(REF_TEST_CTE_PREFIX) - result[name] = cte.sql_body - return result - - -def _extract_mock_sources(test: CompiledSqlTest) -> dict[str, str]: - """Extract mock source CTE bodies keyed by source name.""" - - result: dict[str, str] = {} - if not isinstance(test.payload, CompiledModelSqlTestPayload): - return result - cte: CompileSqlTestCte - for cte in test.payload.authored_ctes: - if cte.name.startswith(SOURCE_TEST_CTE_PREFIX): - name: str = cte.name.removeprefix(SOURCE_TEST_CTE_PREFIX) - result[name] = cte.sql_body - return result - - -def _extract_mock_seeds(test: CompiledSqlTest) -> dict[str, str]: - """Extract mock seed CTE bodies keyed by seed name.""" - - result: dict[str, str] = {} - if not isinstance(test.payload, CompiledModelSqlTestPayload): - return result - cte: CompileSqlTestCte - for cte in test.payload.authored_ctes: - if cte.name.startswith(SEED_TEST_CTE_PREFIX): - name: str = cte.name.removeprefix(SEED_TEST_CTE_PREFIX) - result[name] = cte.sql_body - return result - - -def _extract_mock_dbt_refs(test: CompiledSqlTest) -> dict[str, str]: - """Extract mock dbt ref CTE bodies keyed by fixture name.""" - - result: dict[str, str] = {} - if not isinstance(test.payload, CompiledModelSqlTestPayload): - return result - cte: CompileSqlTestCte - for cte in test.payload.authored_ctes: - if cte.name.startswith(DBT_REF_TEST_CTE_PREFIX): - name: str = cte.name.removeprefix(DBT_REF_TEST_CTE_PREFIX) - result[name] = cte.sql_body - return result - - -def _extract_mock_table_functions(test: CompiledSqlTest) -> dict[str, str]: - """Extract mock table-function CTE bodies keyed by function name.""" - - result: dict[str, str] = {} - if not isinstance(test.payload, CompiledModelSqlTestPayload): - return result - cte: CompileSqlTestCte - for cte in test.payload.authored_ctes: - if cte.name.startswith(TABLE_FN_TEST_CTE_PREFIX): - name: str = cte.name.removeprefix(TABLE_FN_TEST_CTE_PREFIX) - result[name] = cte.sql_body - return result - - -def _dbt_ref_fixture_name(*, package_name: str, model_name: str | None) -> str: - if model_name is None: - return package_name - return f"{package_name}__{model_name}" - - -def _extract_helper_ctes( - test: CompiledSqlTest, -) -> tuple[CompileSqlTestCte, ...]: - """Extract helper CTEs (not mock refs, not mock sources).""" - - helpers: list[CompileSqlTestCte] = [] - if not isinstance(test.payload, CompiledModelSqlTestPayload): - return tuple(helpers) - cte: CompileSqlTestCte - for cte in test.payload.authored_ctes: - if cte.name.startswith(REF_TEST_CTE_PREFIX): - continue - if cte.name.startswith(SOURCE_TEST_CTE_PREFIX): - continue - if cte.name.startswith(SEED_TEST_CTE_PREFIX): - continue - if cte.name.startswith(DBT_REF_TEST_CTE_PREFIX): - continue - if cte.name.startswith(TABLE_FN_TEST_CTE_PREFIX): - continue - if cte.name.startswith(ASSERT_TEST_CTE_PREFIX): - continue - helpers.append(cte) - return tuple(helpers) - - -def _extract_expected_ctes( - test: CompiledSqlTest, -) -> dict[str, str]: - """Build expected model name to CTE SQL body mapping.""" - - result: dict[str, str] = {} - if not isinstance(test.payload, CompiledModelSqlTestPayload): - return result - cte: CompileSqlTestCte - for cte in test.payload.expected_ctes: - cte_name: str = cte.name - model_name: str = cte_name.removeprefix(EXPECTED_TEST_CTE_PREFIX) - result[model_name] = cte.sql_body - return result - - -def _extract_assertion_ctes( - test: CompiledSqlTest, -) -> dict[str, str]: - result: dict[str, str] = {} - if not isinstance(test.payload, CompiledModelSqlTestPayload): - return result - cte: CompileSqlTestCte - for cte in test.payload.assertion_ctes: - assertion_name: str = cte.name.removeprefix(ASSERT_TEST_CTE_PREFIX) - result[assertion_name] = cte.sql_body - return result diff --git a/src/sqlbuild/compiler/planner/_helpers/sql_tests/fixture_validation.py b/src/sqlbuild/compiler/planner/_helpers/sql_tests/fixture_validation.py index f7592c32b8..95e88e958e 100644 --- a/src/sqlbuild/compiler/planner/_helpers/sql_tests/fixture_validation.py +++ b/src/sqlbuild/compiler/planner/_helpers/sql_tests/fixture_validation.py @@ -80,7 +80,7 @@ def build_validated_test_fixtures( ) if empty_error is not None: errors.append( - f"{_fixture_location(test=test, resource_type=resource_type, name=name)}: " + f"{fixture_location(test=test, resource_type=resource_type, name=name)}: " f"expected output '{name}' {empty_error}" ) completed_expected_outputs[name] = completed_sql @@ -120,7 +120,7 @@ def build_validated_test_fixtures( else f"mock {resource_type.value}" ) errors.append( - f"{_fixture_location(test=test, resource_type=resource_type, name=name)}: " + f"{fixture_location(test=test, resource_type=resource_type, name=name)}: " f"{fixture_label} '{name}' column '{column.name}' has incompatible type " f"{column.type}; resource type is {expected_type}. Use an explicit CAST, " "ARRAY_CONSTRUCT, or PARSE_JSON as appropriate" @@ -151,7 +151,7 @@ def build_validated_test_fixtures( def _located_diagnostic(*, test: CompiledSqlTest, diagnostic: RelationFixtureDiagnostic) -> str: return ( - f"{_fixture_location(test=test, resource_type=diagnostic.key[0], name=diagnostic.key[1])}: " + f"{fixture_location(test=test, resource_type=diagnostic.key[0], name=diagnostic.key[1])}: " f"{diagnostic.message}" ) @@ -168,9 +168,11 @@ def _completed_fixture_group( } -def _fixture_location( +def fixture_location( *, test: CompiledSqlTest, resource_type: CompiledResourceType, name: str ) -> str: + """Return the test file and line that defines one fixture or expected-output CTE.""" + prefix: str = { CompiledResourceType.MODEL: "__ref__", CompiledResourceType.SOURCE: "__source__", diff --git a/src/sqlbuild/compiler/planner/_helpers/sql_tests/native_planning.py b/src/sqlbuild/compiler/planner/_helpers/sql_tests/native_planning.py index d4eb11074d..f8a279b15e 100644 --- a/src/sqlbuild/compiler/planner/_helpers/sql_tests/native_planning.py +++ b/src/sqlbuild/compiler/planner/_helpers/sql_tests/native_planning.py @@ -1,4 +1,4 @@ -"""Coarse native SQL-test planning and artifact rendering boundary.""" +"""Native SQL-test chain planning and comparison rendering boundary.""" from __future__ import annotations @@ -22,12 +22,20 @@ from sqlbuild.compiler.planner.main.execution.sql_test_dialect import ( restore_sql_test_dialect_function_names, ) -from sqlbuild.compiler.planner.models import NativeSqlTestArtifact +from sqlbuild.compiler.planner.models import ( + ChainStep, + NativeSqlTestArtifact, + NativeSqlTestPlan, + PlanWarning, + SqlTestAssertionStep, +) +from sqlbuild.compiler.planner.types import WarningSeverity from sqlbuild.compiler.profiling.classes.context import CompileTimingContext from sqlbuild.compiler.profiling.models import CompileTimingCollector from sqlbuild.executor.testing.types import NativeSqlTestRenderingModule _CALL_SUFFIX_SENTINEL: str = "__SQLBUILD_CALL_SUFFIX__" +_NATIVE_WORKERS: int = 4 def plan_and_render_sql_test_artifacts( @@ -39,44 +47,62 @@ def plan_and_render_sql_test_artifacts( ) -> tuple[NativeSqlTestArtifact, ...]: """Plan and render SQL tests in one deterministic native project call.""" - if not tests: - return () - start_ns: int = time.perf_counter_ns() - functions: list[dict[str, object]] = [] - for function in project.functions: - target: str | None = function.destination.qualified_name - if target is None: - continue - udf_prefix, udf_suffix = _render_call_template( - adapter.render_udf_call( - target=target, - call_suffix_sql=_CALL_SUFFIX_SENTINEL, - ) - ) - table_function_prefix, table_function_suffix = _render_call_template( - adapter.render_table_function_call( - target=target, - call_suffix_sql=_CALL_SUFFIX_SENTINEL, + artifacts: list[NativeSqlTestArtifact] = [] + for plan in plan_sql_tests_natively( + project=project, + tests=tests, + adapter=adapter, + sql_analysis_enabled=sql_analysis_enabled, + render_sql=True, + include_plan=False, + ): + if plan.sql is None: + raise NativeSqlTestPlanningError("native SQL-test planning omitted rendered SQL") + artifacts.append( + NativeSqlTestArtifact( + sql=plan.sql, + model_names=plan.model_names, + error_messages=sql_test_plan_error_messages(warnings=plan.warnings), ) ) - functions.append( - { - "name": function.name, - "udfPrefix": udf_prefix, - "udfSuffix": udf_suffix, - "tableFunctionPrefix": table_function_prefix, - "tableFunctionSuffix": table_function_suffix, - } + return tuple(artifacts) + + +def sql_test_plan_error_messages(*, warnings: tuple[PlanWarning, ...]) -> tuple[str, ...]: + """Return each distinct ERROR-severity planning message for one SQL test, in order.""" + + return tuple( + dict.fromkeys( + warning.message for warning in warnings if warning.severity is WarningSeverity.ERROR ) + ) + + +def plan_sql_tests_natively( + *, + project: CompiledProject, + tests: tuple[CompiledSqlTest, ...], + adapter: BaseAdapter, + sql_analysis_enabled: bool, + render_sql: bool, + include_plan: bool = True, +) -> tuple[NativeSqlTestPlan, ...]: + """Plan SQL-test chains and assertions in one native batch, optionally rendering SQL.""" + + if not tests: + return () + start_ns: int = time.perf_counter_ns() request: dict[str, object] = { "models": _model_requests(project=project), - "functions": functions, - "tests": [_test_request(test) for test in tests], + "functions": _function_requests(project=project, adapter=adapter), + "tests": [_test_request(test=test) for test in tests], "sqlAnalysisEnabled": sql_analysis_enabled, "sqlAnalysisDialect": adapter.sql_analysis_dialect(), "setDifferenceOperator": adapter.render_set_difference_operator(), "requiresDerivedTableAliases": adapter.requires_derived_table_aliases(), - "workers": 4, + "workers": _NATIVE_WORKERS, + "renderSql": render_sql, + "includePlan": include_plan, } try: native_response: str = cast( @@ -85,12 +111,7 @@ def plan_and_render_sql_test_artifacts( orjson.dumps(request, option=orjson.OPT_SORT_KEYS).decode() ) except ValueError as error: - message: str = str(error) - if message.startswith("compile_input:"): - raise CompileInputError(message.removeprefix("compile_input:")) from None - if message.startswith("planner_input:"): - raise PlannerInputError(message.removeprefix("planner_input:")) from None - raise NativeSqlTestPlanningError(f"native SQL-test planning failed: {message}") from error + raise _planning_error(error=error) from None response_payload: object = orjson.loads(native_response) if not isinstance(response_payload, dict): raise NativeSqlTestPlanningError( @@ -110,32 +131,10 @@ def plan_and_render_sql_test_artifacts( raise NativeSqlTestPlanningError( "native SQL-test planning returned an invalid batch response" ) - artifacts: list[NativeSqlTestArtifact] = [] - for value in response: - payload: dict[str, Any] | None = ( - cast(dict[str, Any], value) if isinstance(value, dict) else None - ) - sql: object = payload.get("sql") if payload is not None else None - model_names: object = payload.get("modelNames") if payload is not None else None - warnings: object = payload.get("warnings") if payload is not None else None - if ( - not isinstance(sql, str) - or not isinstance(model_names, list) - or not all(isinstance(name, str) for name in model_names) - or not isinstance(warnings, list) - or not all(isinstance(warning, dict) for warning in warnings) - ): - raise NativeSqlTestPlanningError("native SQL-test planning returned an invalid result") - artifacts.append( - NativeSqlTestArtifact( - sql=restore_sql_test_dialect_function_names( - sql=sql, - dialect=adapter.sql_analysis_dialect(), - ), - model_names=tuple(cast(list[str], model_names)), - warnings=tuple(cast(list[dict[str, object]], warnings)), - ) - ) + dialect: str | None = adapter.sql_analysis_dialect() + plans: tuple[NativeSqlTestPlan, ...] = tuple( + _plan_from_payload(value=value, dialect=dialect) for value in response + ) elapsed_ns: int = time.perf_counter_ns() - start_ns boundary_overhead_ns: int = max(0, elapsed_ns - planning_ns - rendering_ns) collector: CompileTimingCollector | None = CompileTimingContext.active.get() @@ -145,27 +144,197 @@ def plan_and_render_sql_test_artifacts( elapsed_ns=planning_ns + boundary_overhead_ns, ) collector.add(phase="comparison_render_ms", elapsed_ns=rendering_ns) - return tuple(artifacts) + return plans + + +def resolve_sql_test_model_chains( + *, project: CompiledProject, tests: tuple[CompiledSqlTest, ...] +) -> tuple[tuple[str, ...], ...]: + """Return each test's ordered unmocked model chain; direct-logic tests have none.""" + + if not tests: + return () + request: dict[str, object] = { + "models": _model_requests(project=project), + "tests": [_test_request(test=test) for test in tests], + } + try: + native_response: str = cast( + NativeSqlTestRenderingModule, _native + ).resolve_sql_test_chains_json(orjson.dumps(request, option=orjson.OPT_SORT_KEYS).decode()) + except ValueError as error: + raise _planning_error(error=error) from None + response_payload: object = orjson.loads(native_response) + chains: object = response_payload.get("chains") if isinstance(response_payload, dict) else None + if not isinstance(chains, list) or len(chains) != len(tests): + raise NativeSqlTestPlanningError( + "native SQL-test chain resolution returned an invalid response" + ) + return tuple(_string_tuple(value=chain, context="chain") for chain in chains) + + +def _planning_error(*, error: ValueError) -> Exception: + message: str = str(error) + if message.startswith("compile_input:"): + return CompileInputError(message.removeprefix("compile_input:")) + if message.startswith("planner_input:"): + return PlannerInputError(message.removeprefix("planner_input:")) + return NativeSqlTestPlanningError(f"native SQL-test planning failed: {message}") + + +def _plan_from_payload(*, value: object, dialect: str | None) -> NativeSqlTestPlan: + payload: dict[str, Any] = _object(value=value, context="result") + sql: object = payload.get("sql") + chain: object = payload.get("chain") + assertions: object = payload.get("assertions") + warnings: object = payload.get("warnings") + if ( + (sql is not None and not isinstance(sql, str)) + or not isinstance(chain, list) + or not isinstance(assertions, list) + or not isinstance(warnings, list) + ): + raise NativeSqlTestPlanningError("native SQL-test planning returned an invalid result") + return NativeSqlTestPlan( + chain=tuple(_chain_step(value=step) for step in chain), + assertions=tuple(_assertion_step(value=step) for step in assertions), + model_names=_string_tuple(value=payload.get("modelNames"), context="model names"), + warnings=tuple(_warning(value=warning) for warning in warnings), + sql=( + restore_sql_test_dialect_function_names(sql=sql, dialect=dialect) + if isinstance(sql, str) + else None + ), + ) + + +def _chain_step(*, value: object) -> ChainStep: + payload: dict[str, Any] = _object(value=value, context="chain step") + return ChainStep( + model_name=_string(value=payload.get("modelName"), context="chain step model"), + resolved_sql=_string(value=payload.get("resolvedSql"), context="chain step SQL"), + expected_cte_sql=_optional_string( + value=payload.get("expectedCteSql"), context="chain step expected SQL" + ), + lifted_ctes=_cte_pairs(value=payload.get("liftedCtes")), + comparison_body_sql=_optional_string( + value=payload.get("comparisonBodySql"), context="chain step comparison SQL" + ), + expected_columns=_optional_string_tuple( + value=payload.get("expectedColumns"), context="chain step expected columns" + ), + ) + + +def _assertion_step(*, value: object) -> SqlTestAssertionStep: + payload: dict[str, Any] = _object(value=value, context="assertion step") + return SqlTestAssertionStep( + name=_string(value=payload.get("name"), context="assertion name"), + resolved_sql=_string(value=payload.get("resolvedSql"), context="assertion SQL"), + lifted_ctes=_cte_pairs(value=payload.get("liftedCtes")), + comparison_body_sql=_optional_string( + value=payload.get("comparisonBodySql"), context="assertion comparison SQL" + ), + ) + + +def _warning(*, value: object) -> PlanWarning: + payload: dict[str, Any] = _object(value=value, context="warning") + return PlanWarning( + model_name=_optional_string(value=payload.get("modelName"), context="warning model"), + severity=WarningSeverity( + _string(value=payload.get("severity"), context="warning severity") + ), + message=_string(value=payload.get("message"), context="warning message"), + ) + + +def _cte_pairs(*, value: object) -> tuple[tuple[str, str], ...]: + if not isinstance(value, list): + raise NativeSqlTestPlanningError("native SQL-test planning returned invalid lifted CTEs") + pairs: list[tuple[str, str]] = [] + for pair in value: + name, sql = _string_tuple(value=pair, context="lifted CTE") + pairs.append((name, sql)) + return tuple(pairs) + + +def _object(*, value: object, context: str) -> dict[str, Any]: + if not isinstance(value, dict): + raise NativeSqlTestPlanningError(f"native SQL-test planning returned an invalid {context}") + return cast(dict[str, Any], value) + + +def _string(*, value: object, context: str) -> str: + if not isinstance(value, str): + raise NativeSqlTestPlanningError(f"native SQL-test planning returned an invalid {context}") + return value + + +def _optional_string(*, value: object, context: str) -> str | None: + return None if value is None else _string(value=value, context=context) + + +def _optional_string_tuple(*, value: object, context: str) -> tuple[str, ...] | None: + return None if value is None else _string_tuple(value=value, context=context) + + +def _string_tuple(*, value: object, context: str) -> tuple[str, ...]: + if not isinstance(value, list): + raise NativeSqlTestPlanningError(f"native SQL-test planning returned an invalid {context}") + return tuple(_string(value=item, context=context) for item in value) + + +def _function_requests( + *, project: CompiledProject, adapter: BaseAdapter +) -> list[dict[str, object]]: + functions: list[dict[str, object]] = [] + for function in project.functions: + target: str | None = function.destination.qualified_name + if target is None: + continue + udf_prefix, udf_suffix = _render_call_template( + rendered=adapter.render_udf_call( + target=target, + call_suffix_sql=_CALL_SUFFIX_SENTINEL, + ) + ) + table_function_prefix, table_function_suffix = _render_call_template( + rendered=adapter.render_table_function_call( + target=target, + call_suffix_sql=_CALL_SUFFIX_SENTINEL, + ) + ) + functions.append( + { + "name": function.name, + "udfPrefix": udf_prefix, + "udfSuffix": udf_suffix, + "tableFunctionPrefix": table_function_prefix, + "tableFunctionSuffix": table_function_suffix, + } + ) + return functions -def _test_request(test: CompiledSqlTest) -> dict[str, object]: +def _test_request(*, test: CompiledSqlTest) -> dict[str, object]: payload: CompiledModelSqlTestPayload | CompiledDirectLogicSqlTestPayload = test.payload if isinstance(payload, CompiledDirectLogicSqlTestPayload): payload_request: dict[str, object] = { "kind": "direct", "mode": payload.mode.value, - "actualCte": _cte_request(payload.actual_cte), - "expectedCte": _cte_request(payload.expected_cte), - "helperCtes": [_cte_request(cte) for cte in payload.helper_ctes], + "actualCte": _cte_request(cte=payload.actual_cte), + "expectedCte": _cte_request(cte=payload.expected_cte), + "helperCtes": [_cte_request(cte=cte) for cte in payload.helper_ctes], } else: payload_request = { "kind": "model", - "authoredCtes": [_cte_request(cte) for cte in payload.authored_ctes], + "authoredCtes": [_cte_request(cte=cte) for cte in payload.authored_ctes], "modelQueryOverrides": payload.model_query_overrides, - "expectedCtes": [_cte_request(cte) for cte in payload.expected_ctes], + "expectedCtes": [_cte_request(cte=cte) for cte in payload.expected_ctes], "expectedModelNames": list(payload.expected_model_names), - "assertionCtes": [_cte_request(cte) for cte in payload.assertion_ctes], + "assertionCtes": [_cte_request(cte=cte) for cte in payload.assertion_ctes], } return { "name": test.name, @@ -191,11 +360,11 @@ def _model_requests(*, project: CompiledProject) -> list[dict[str, object]]: return requests -def _cte_request(cte: CompileSqlTestCte) -> dict[str, str]: +def _cte_request(*, cte: CompileSqlTestCte) -> dict[str, str]: return {"name": cte.name, "sqlBody": cte.sql_body} -def _render_call_template(rendered: str) -> tuple[str, str]: +def _render_call_template(*, rendered: str) -> tuple[str, str]: prefix, separator, suffix = rendered.partition(_CALL_SUFFIX_SENTINEL) if not separator or _CALL_SUFFIX_SENTINEL in suffix: raise NativeSqlTestPlanningError("adapter SQL function call template is not deterministic") diff --git a/src/sqlbuild/compiler/planner/_helpers/sql_tests/sql_test_assembly.py b/src/sqlbuild/compiler/planner/_helpers/sql_tests/sql_test_assembly.py deleted file mode 100644 index 4bb6d2bca2..0000000000 --- a/src/sqlbuild/compiler/planner/_helpers/sql_tests/sql_test_assembly.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Public SQL test planning helpers.""" - -from __future__ import annotations - -from sqlbuild.adapter.contract.classes.base_adapter import BaseAdapter -from sqlbuild.compiler.compile.models import CompiledProject, CompiledSqlTest -from sqlbuild.compiler.planner._helpers.sql_tests.assembly import ( - SqlTestPlanningContext, - plan_test, -) -from sqlbuild.compiler.planner.models import PlanWarning, SqlTestPlanEntry - - -def build_sql_test_plan_entry( - *, - test: CompiledSqlTest, - project: CompiledProject, - adapter: BaseAdapter, - sql_analysis_enabled: bool = False, - planning_context: SqlTestPlanningContext | None = None, -) -> tuple[SqlTestPlanEntry, tuple[PlanWarning, ...]]: - """Build a SQL test plan entry without warehouse state.""" - - return plan_test( - test=test, - project=project, - adapter=adapter, - sql_analysis_enabled=sql_analysis_enabled, - planning_context=planning_context, - ) diff --git a/src/sqlbuild/compiler/planner/_helpers/sql_tests/sql_test_planning_context.py b/src/sqlbuild/compiler/planner/_helpers/sql_tests/sql_test_planning_context.py deleted file mode 100644 index e129d85b8a..0000000000 --- a/src/sqlbuild/compiler/planner/_helpers/sql_tests/sql_test_planning_context.py +++ /dev/null @@ -1,15 +0,0 @@ -"""SQL test planning context entrypoint.""" - -from sqlbuild.compiler.compile.models import CompiledProject -from sqlbuild.compiler.planner._helpers.sql_tests.assembly import ( - SqlTestPlanningContext, -) -from sqlbuild.compiler.planner._helpers.sql_tests.assembly import ( - build_sql_test_planning_context as _build_sql_test_planning_context, -) - - -def build_sql_test_planning_context(*, project: CompiledProject) -> SqlTestPlanningContext: - """Build reusable immutable lookup context for static SQL test planning.""" - - return _build_sql_test_planning_context(project=project) diff --git a/src/sqlbuild/compiler/planner/constants.py b/src/sqlbuild/compiler/planner/constants.py index 9070577ac6..5f059d15dd 100644 --- a/src/sqlbuild/compiler/planner/constants.py +++ b/src/sqlbuild/compiler/planner/constants.py @@ -11,6 +11,7 @@ ) EMPTY_FINGERPRINT_METADATA_JSON: str = "{}" MODEL_SELECTOR_ROOT: str = "models" +SQL_FILE_SELECTOR_SUFFIX: str = ".sql" MODEL_SELECTOR_ROOT_PREFIX: str = "models/" SELECTOR_MISSING_NAME_ERROR_FRAGMENT: str = "no name" PATH_SELECTOR_SEPARATOR: str = "~" diff --git a/src/sqlbuild/compiler/planner/main/changes/relation_replacement.py b/src/sqlbuild/compiler/planner/main/changes/relation_replacement.py new file mode 100644 index 0000000000..5d53c18497 --- /dev/null +++ b/src/sqlbuild/compiler/planner/main/changes/relation_replacement.py @@ -0,0 +1,16 @@ +"""Public incremental relation-replacement predicate.""" + +from __future__ import annotations + +from sqlbuild.compiler.planner.types import MaterializationType, PlanAction + + +def replaces_incremental_relation( + *, materialization_type: MaterializationType, action: PlanAction +) -> bool: + """Identify incremental replacements whose bounds must ignore the destination maximum.""" + + return ( + materialization_type == MaterializationType.INCREMENTAL + and action == PlanAction.CREATE_TABLE + ) diff --git a/src/sqlbuild/compiler/planner/main/execution/sql_test_model_chain.py b/src/sqlbuild/compiler/planner/main/execution/sql_test_model_chain.py index c55e4f8b96..2353f110e2 100644 --- a/src/sqlbuild/compiler/planner/main/execution/sql_test_model_chain.py +++ b/src/sqlbuild/compiler/planner/main/execution/sql_test_model_chain.py @@ -1,15 +1,19 @@ """Public planner entrypoint for batched SQL-test model closure discovery.""" from sqlbuild.compiler.compile.models import CompiledObjectKey, CompiledProject, CompiledSqlTest -from sqlbuild.compiler.planner._helpers.sql_tests.assembly import build_sql_test_planning_context +from sqlbuild.compiler.planner._helpers.sql_tests.native_planning import ( + resolve_sql_test_model_chains, +) def sql_test_model_chain_names_by_key( *, project: CompiledProject, tests: tuple[CompiledSqlTest, ...] ) -> dict[CompiledObjectKey, tuple[str, ...]]: - """Return model closures using one shared project dependency index.""" + """Return model closures resolved natively in one project batch.""" - return build_sql_test_planning_context( - project=project, - tests=tests, - ).chain_names_by_test_key + return { + test.key: chain + for test, chain in zip( + tests, resolve_sql_test_model_chains(project=project, tests=tests), strict=True + ) + } diff --git a/src/sqlbuild/compiler/planner/models.py b/src/sqlbuild/compiler/planner/models.py index 6f20ebad53..7c87d97531 100644 --- a/src/sqlbuild/compiler/planner/models.py +++ b/src/sqlbuild/compiler/planner/models.py @@ -2,7 +2,6 @@ from __future__ import annotations -from collections import OrderedDict from collections.abc import Callable from dataclasses import dataclass, field from datetime import UTC, datetime @@ -89,11 +88,11 @@ @dataclass(frozen=True) class NativeSqlTestArtifact: - """One native-planned SQL-test artifact and its compact diagnostics.""" + """One native-planned SQL-test artifact and its distinct planning error messages.""" sql: str model_names: tuple[str, ...] - warnings: tuple[dict[str, object], ...] + error_messages: tuple[str, ...] = () @dataclass(frozen=True) @@ -141,26 +140,6 @@ class RelationFixturePlanningContext: expected_types: dict[FixtureKey, dict[str, str]] -@dataclass(frozen=True) -class TestFunctionAnalysisContext: - """Function locations and reusable marker targets for SQL-test analysis.""" - - locations: dict[str, str] - marker_targets: tuple[tuple[str, str, str], ...] - - -@dataclass(frozen=True) -class SqlTestPlanningContext: - """Project-wide SQL-test indexes and reusable chain topology.""" - - models_by_name: dict[str, CompiledModel] - model_dependencies: dict[str, frozenset[str]] - function_locations: dict[str, CompiledRelationLocation] - qualified_function_locations: dict[str, str] - function_analysis_context: TestFunctionAnalysisContext - chain_names_by_test_key: dict[CompiledObjectKey, tuple[str, ...]] - - @dataclass(frozen=True) class GraphNodeKey: """Neutral graph key matching fingerprint identity fields.""" @@ -1189,6 +1168,7 @@ class ChainStep: expected_cte_sql: str | None = None lifted_ctes: tuple[tuple[str, str], ...] = field(default_factory=tuple) comparison_body_sql: str | None = None + expected_columns: tuple[str, ...] | None = None @dataclass(frozen=True) @@ -1201,16 +1181,6 @@ class SqlTestAssertionStep: comparison_body_sql: str | None = None -@dataclass(frozen=True) -class SqlAnalysisResolvedTestSql: - """SQL analysis-resolved test SQL plus reusable CTE state for downstream refs.""" - - resolved_sql: str - cte_body_sql: str - generated_ctes: OrderedDict[str, str] - reachable_mock_names: frozenset[str] = frozenset() - - @dataclass(frozen=True) class SqlTestPlanEntry: """Per-test execution plan entry with chained resolution.""" @@ -1237,6 +1207,26 @@ class SqlTestPlanEntry: sql_analysis_enabled: bool = True +@dataclass(frozen=True) +class NativeSqlTestPlan: + """Native chain and assertion steps for one SQL test, with SQL when rendered.""" + + chain: tuple[ChainStep, ...] + assertions: tuple[SqlTestAssertionStep, ...] + model_names: tuple[str, ...] + warnings: tuple[PlanWarning, ...] + sql: str | None = None + + +@dataclass(frozen=True) +class SqlTestPlanResult: + """One SQL test's plan entry and warnings, or the fixture diagnostics preventing it.""" + + entry: SqlTestPlanEntry | None + warnings: tuple[PlanWarning, ...] = field(default_factory=tuple) + fixture_diagnostics: tuple[str, ...] = field(default_factory=tuple) + + @dataclass(frozen=True) class ScenarioGraphPlan: """Inferred graph slice and fixture boundaries for one SQL scenario.""" diff --git a/src/sqlbuild/executor/build/_helpers/scheduler.py b/src/sqlbuild/executor/build/_helpers/scheduler.py index bcfd0097a2..98a75846db 100644 --- a/src/sqlbuild/executor/build/_helpers/scheduler.py +++ b/src/sqlbuild/executor/build/_helpers/scheduler.py @@ -9,6 +9,9 @@ from sqlbuild.compiler.compile.main.cursor_intrinsics import resolve_cursor_intrinsics from sqlbuild.compiler.compile.models import CompiledObjectKey from sqlbuild.compiler.planner.constants import MICROBATCH_END_SENTINEL, MICROBATCH_START_SENTINEL +from sqlbuild.compiler.planner.main.changes.relation_replacement import ( + replaces_incremental_relation, +) from sqlbuild.compiler.planner.models import ModelPlanEntry from sqlbuild.compiler.planner.types import ( IncrementalMode, @@ -123,10 +126,8 @@ def _dispatch_model( ) is_microbatch: bool = entry.incremental_mode == IncrementalMode.MICROBATCH - is_full_refresh_microbatch: bool = ( - is_microbatch - and entry.action == PlanAction.CREATE_TABLE - and entry.materialization_type == MaterializationType.INCREMENTAL + is_full_refresh_microbatch: bool = is_microbatch and replaces_incremental_relation( + materialization_type=entry.materialization_type, action=entry.action ) if is_microbatch and entry.action in INCREMENTAL_ACTIONS: diff --git a/src/sqlbuild/executor/run/_helpers/materializations/table_cursor.py b/src/sqlbuild/executor/run/_helpers/materializations/table_cursor.py index 320d5bbb0c..89ea1fbabe 100644 --- a/src/sqlbuild/executor/run/_helpers/materializations/table_cursor.py +++ b/src/sqlbuild/executor/run/_helpers/materializations/table_cursor.py @@ -4,6 +4,9 @@ from dataclasses import replace +from sqlbuild.compiler.planner.main.changes.relation_replacement import ( + replaces_incremental_relation, +) from sqlbuild.compiler.planner.main.execution.future_cursor_warning import ( future_cursor_cap_warning, ) @@ -54,7 +57,12 @@ def resolve_table_cursor( target_database=targets.target_database, target_schema=targets.target_schema, target_name=targets.target_table, - spec=build_runtime_cursor_spec(entry=entry), + spec=build_runtime_cursor_spec( + entry=entry, + read_destination_cursor=not replaces_incremental_relation( + materialization_type=entry.materialization_type, action=entry.action + ), + ), watermark_resolver=context.watermark_resolver, ) if bounds is None: diff --git a/src/sqlbuild/executor/testing/_helpers/comparison_sql.py b/src/sqlbuild/executor/testing/_helpers/comparison_sql.py deleted file mode 100644 index 4fe9c39042..0000000000 --- a/src/sqlbuild/executor/testing/_helpers/comparison_sql.py +++ /dev/null @@ -1,385 +0,0 @@ -"""Polyglot-backed formatting helpers for SQL unit-test comparison SQL.""" - -from __future__ import annotations - -import logging -import re -from collections import OrderedDict -from typing import Any - -from sqlbuild.adapter.contract.types import BuiltinAdapter -from sqlbuild.compiler.planner.main.execution.sql_test_dialect import ( - restore_sql_test_dialect_function_names, -) -from sqlbuild.compiler.planner.models import ChainStep, SqlTestPlanEntry -from sqlbuild.compiler.sql_analysis.main.import_polyglot_sql import import_polyglot_sql -from sqlbuild.diagnostics.main.log_debug_event import log_debug_event -from sqlbuild.executor.testing.constants import SQL_TEST_BACKTICK_IDENTIFIER_QUOTE - -_DEBUG_LOGGER: logging.Logger = logging.getLogger("sqlbuild.execution") - -_IDENTIFIER_CHAR_PATTERN: re.Pattern[str] = re.compile(r"[^a-zA-Z0-9_]+") -_BACKTICK_IDENTIFIER_PATTERN: re.Pattern[str] = re.compile(r"`[^`]+`(?:\s*\.\s*`[^`]+`)*") -_LEADING_WITH_PATTERN: re.Pattern[str] = re.compile( - r"\A(?:\s|--[^\n]*(?:\n|\Z)|/\*.*?\*/)*WITH\b", - flags=re.IGNORECASE | re.DOTALL, -) -_TRAILING_LINE_COMMENT_PATTERN: re.Pattern[str] = re.compile(r"--[^\n]*\Z") - - -def lift_step_ctes( - *, - sql: str, - lifted_ctes: OrderedDict[str, str], - sql_analysis_enabled: bool = True, - sql_analysis_dialect: str | None = None, -) -> tuple[str, OrderedDict[str, str]]: - """Lift a step's top-level CTEs into the shared comparison query when possible.""" - - if not sql_analysis_enabled: - return sql, lifted_ctes - split_sql: tuple[tuple[tuple[str, str], ...], str] | None = _split_top_level_with( - sql=sql, - sql_analysis_dialect=sql_analysis_dialect, - ) - if split_sql is None: - return sql, lifted_ctes - step_ctes: tuple[tuple[str, str], ...] - body_sql: str - step_ctes, body_sql = split_sql - cte_name: str - cte_sql: str - for cte_name, cte_sql in step_ctes: - existing_name: str | None = _existing_cte_name(lifted_ctes=lifted_ctes, cte_name=cte_name) - existing_sql: str | None = lifted_ctes.get(existing_name) if existing_name else None - if existing_sql is not None and existing_sql != cte_sql: - return sql, lifted_ctes - updated_ctes: OrderedDict[str, str] = OrderedDict(lifted_ctes) - for cte_name, cte_sql in step_ctes: - existing_name = _existing_cte_name(lifted_ctes=updated_ctes, cte_name=cte_name) - if existing_name is None: - updated_ctes[cte_name] = cte_sql - return body_sql, updated_ctes - - -def merge_lifted_ctes( - *, - ctes: tuple[tuple[str, str], ...], - lifted_ctes: OrderedDict[str, str], - sql_analysis_dialect: str | None = None, -) -> OrderedDict[str, str] | None: - """Canonicalize and merge already identified top-level CTE bodies.""" - - canonical_ctes: tuple[tuple[str, str], ...] = tuple( - ( - cte_name, - format_sql(sql=cte_sql, sql_analysis_dialect=sql_analysis_dialect), - ) - for cte_name, cte_sql in ctes - ) - for cte_name, cte_sql in canonical_ctes: - existing_name: str | None = _existing_cte_name(lifted_ctes=lifted_ctes, cte_name=cte_name) - existing_sql: str | None = lifted_ctes.get(existing_name) if existing_name else None - if existing_sql is not None and existing_sql != cte_sql: - return None - updated_ctes: OrderedDict[str, str] = OrderedDict(lifted_ctes) - for cte_name, cte_sql in canonical_ctes: - if _existing_cte_name(lifted_ctes=updated_ctes, cte_name=cte_name) is None: - updated_ctes[cte_name] = cte_sql - return updated_ctes - - -def lift_preanalyzed_step_ctes( - *, - sql: str, - complete_sql: str, - preanalyzed_ctes: tuple[tuple[str, str], ...], - lifted_ctes: OrderedDict[str, str], - sql_analysis_enabled: bool, - sql_analysis_dialect: str | None = None, -) -> tuple[str, OrderedDict[str, str]]: - """Reuse generated CTEs while still lifting authored top-level CTEs.""" - - updated_ctes: OrderedDict[str, str] | None = merge_lifted_ctes( - ctes=preanalyzed_ctes, - lifted_ctes=lifted_ctes, - sql_analysis_dialect=sql_analysis_dialect, - ) - if updated_ctes is None: - return complete_sql, lifted_ctes - if not sql_analysis_enabled or _LEADING_WITH_PATTERN.match(sql) is None: - return sql, updated_ctes - return lift_step_ctes( - sql=sql, - lifted_ctes=updated_ctes, - sql_analysis_enabled=True, - sql_analysis_dialect=sql_analysis_dialect, - ) - - -def cte_definition_sql(*, name: str, sql: str) -> str: - """Wrap a CTE body without letting a trailing line comment consume delimiters.""" - - body: str = sql.rstrip() - terminator: str = "\n" if _TRAILING_LINE_COMMENT_PATTERN.search(body) is not None else "" - return f"{name} AS ({body}{terminator})" - - -def format_sql( - *, sql: str, sql_analysis_dialect: str | None = None, sql_analysis_enabled: bool = True -) -> str: - """Format generated comparison SQL when Polyglot is available.""" - - if not sql_analysis_enabled: - return sql - protected_sql: str = sql - protected_identifiers: dict[str, str] = {} - if sql_analysis_dialect in {BuiltinAdapter.BIGQUERY, BuiltinAdapter.DATABRICKS} and ( - SQL_TEST_BACKTICK_IDENTIFIER_QUOTE in sql - ): - protected_sql, protected_identifiers = _protect_backtick_identifiers(sql) - polyglot_module: Any = import_polyglot_sql() - try: - formatted_sql: str = str( - polyglot_module.format( - protected_sql, - dialect=sql_analysis_dialect or "generic", - ) - ) - restored_sql: str = _restore_protected_identifiers( - sql=formatted_sql, - protected_identifiers=protected_identifiers, - ) - return restore_sql_test_dialect_function_names( - sql=restored_sql, - dialect=sql_analysis_dialect, - ) - except polyglot_module.PolyglotError: - return sql - - -def unique_cte_suffix( - *, model_name: str, cte_name_counts: dict[str, int] -) -> tuple[str, dict[str, int]]: - """Return a readable generated CTE suffix for a chain step and updated counts.""" - - base_suffix: str = _sanitize_cte_suffix(model_name) - updated_counts: dict[str, int] = dict(cte_name_counts) - count: int = updated_counts.get(base_suffix, 0) + 1 - updated_counts[base_suffix] = count - if count == 1: - return base_suffix, updated_counts - return f"{base_suffix}_{count}", updated_counts - - -def build_chain_comparison_parts( - *, - test_entry: SqlTestPlanEntry, - set_difference_operator: str, - sql_analysis_dialect: str | None = None, -) -> tuple[OrderedDict[str, str], list[str], list[str], dict[str, int]]: - """Build actual/expected CTEs only for explicitly asserted model steps.""" - - lifted_ctes: OrderedDict[str, str] = OrderedDict() - comparison_ctes: list[str] = [] - select_parts: list[str] = [] - cte_name_counts: dict[str, int] = {} - step_index: int - step: ChainStep - for step_index, step in enumerate(test_entry.chain): - cte_suffix: str - cte_suffix, cte_name_counts = unique_cte_suffix( - model_name=step.model_name, - cte_name_counts=cte_name_counts, - ) - actual_cte: str = f"__actual__{cte_suffix}" - expected_cte: str = f"__expected__{cte_suffix}" - if step.expected_cte_sql is None and not _assertion_uses_actual( - actual_cte=actual_cte, - test_entry=test_entry, - ): - continue - actual_sql: str - if step.lifted_ctes: - actual_sql, lifted_ctes = lift_preanalyzed_step_ctes( - sql=step.comparison_body_sql or step.resolved_sql, - complete_sql=step.resolved_sql, - preanalyzed_ctes=step.lifted_ctes, - lifted_ctes=lifted_ctes, - sql_analysis_enabled=test_entry.sql_analysis_enabled, - sql_analysis_dialect=sql_analysis_dialect, - ) - else: - actual_sql, lifted_ctes = lift_step_ctes( - sql=step.resolved_sql, - lifted_ctes=lifted_ctes, - sql_analysis_enabled=test_entry.sql_analysis_enabled, - sql_analysis_dialect=sql_analysis_dialect, - ) - comparison_ctes.append(cte_definition_sql(name=actual_cte, sql=actual_sql)) - if step.expected_cte_sql is None: - continue - expected_sql: str - expected_sql, lifted_ctes = lift_step_ctes( - sql=step.expected_cte_sql, - lifted_ctes=lifted_ctes, - sql_analysis_enabled=test_entry.sql_analysis_enabled, - sql_analysis_dialect=sql_analysis_dialect, - ) - comparison_ctes.append(cte_definition_sql(name=expected_cte, sql=expected_sql)) - select_parts.append( - "SELECT " - f"{step_index} AS step_index, " - f"'{_escape_sql_string(step.model_name)}' AS model_name, " - f"(SELECT COUNT(*) FROM {actual_cte}) AS actual_count, " - f"(SELECT COUNT(*) FROM {expected_cte}) AS expected_count, " - f"(SELECT COUNT(*) FROM (" - f"SELECT * FROM {actual_cte} {set_difference_operator} SELECT * FROM {expected_cte}" - f") AS __sqlbuild_mismatch) AS unexpected_count, " - f"(SELECT COUNT(*) FROM (" - f"SELECT * FROM {expected_cte} {set_difference_operator} SELECT * FROM {actual_cte}" - f") AS __sqlbuild_missing) AS missing_count" - ) - return lifted_ctes, comparison_ctes, select_parts, cte_name_counts - - -def _split_top_level_with( - *, sql: str, sql_analysis_dialect: str | None = None -) -> tuple[tuple[tuple[str, str], ...], str] | None: - """Split top-level WITH CTEs from a SQL statement with Polyglot if available.""" - - polyglot_module: Any = import_polyglot_sql() - protected_sql: str = sql - protected_identifiers: dict[str, str] = {} - if SQL_TEST_BACKTICK_IDENTIFIER_QUOTE in sql: - protected_sql, protected_identifiers = _protect_backtick_identifiers(sql) - - try: - dialect: str = sql_analysis_dialect or "generic" - parsed: Any = polyglot_module.parse_one(protected_sql, dialect=dialect) - except polyglot_module.PolyglotError as error: - log_debug_event( - logger=_DEBUG_LOGGER, - message="comparison SQL top-level WITH parse failed; falling back", - sqlbuild_error=str(error), - ) - return None - - parsed_dict: dict[str, Any] = parsed.to_dict() - select_dict: dict[str, Any] | None = parsed_dict.get("select") - if select_dict is None: - return None - parsed_with: dict[str, Any] | None = select_dict.get("with") - if parsed_with is None: - return None - - cte_parts: list[tuple[str, str]] = [] - cte: dict[str, Any] - for cte in parsed_with.get("ctes", ()): - alias: Any | None = cte.get("alias") - if not isinstance(alias, dict): - return None - name: Any | None = alias.get("name") - body: Any | None = cte.get("this") - if name is None or body is None: - return None - cte_sql: str | None = _generate_one( - polyglot_module=polyglot_module, - expression=body, - dialect=dialect, - ) - if cte_sql is None: - return None - cte_parts.append( - ( - str(name), - _restore_protected_identifiers( - sql=cte_sql, - protected_identifiers=protected_identifiers, - ), - ) - ) - - select_dict["with"] = None - body_sql: str | None = _generate_one( - polyglot_module=polyglot_module, - expression=parsed_dict, - dialect=dialect, - ) - if body_sql is None: - return None - return ( - tuple(cte_parts), - _restore_protected_identifiers( - sql=body_sql, - protected_identifiers=protected_identifiers, - ), - ) - - -def _assertion_uses_actual(*, actual_cte: str, test_entry: SqlTestPlanEntry) -> bool: - return any( - actual_cte.lower() in assertion.resolved_sql.lower() for assertion in test_entry.assertions - ) - - -def _escape_sql_string(value: str) -> str: - return value.replace("'", "''") - - -def _sanitize_cte_suffix(model_name: str) -> str: - """Convert a model name into a safe unquoted SQL identifier suffix.""" - - suffix: str = _IDENTIFIER_CHAR_PATTERN.sub("_", model_name).strip("_").lower() - if not suffix: - return "model" - if suffix[0].isdigit(): - return f"model_{suffix}" - return suffix - - -def _existing_cte_name(*, lifted_ctes: OrderedDict[str, str], cte_name: str) -> str | None: - normalized_name: str = cte_name.lower() - existing_name: str - for existing_name in lifted_ctes: - if existing_name.lower() == normalized_name: - return existing_name - return None - - -def _generate_one(*, polyglot_module: Any, expression: Any, dialect: str) -> str | None: - try: - generated: list[str] = polyglot_module.generate(expression, dialect=dialect) - except Exception as error: - log_debug_event( - logger=_DEBUG_LOGGER, - message="comparison SQL generation failed; falling back", - sqlbuild_error=str(error), - ) - return None - if len(generated) != 1: - return None - return generated[0] - - -def _protect_backtick_identifiers(sql: str) -> tuple[str, dict[str, str]]: - protected_identifiers: dict[str, str] = {} - protected_sql_parts: list[str] = [] - previous_end: int = 0 - match: re.Match[str] - for match in _BACKTICK_IDENTIFIER_PATTERN.finditer(sql): - placeholder: str = f"SQB_PROTECTED_IDENTIFIER_{len(protected_identifiers)}" - protected_identifiers[placeholder] = match.group(0) - protected_sql_parts.extend((sql[previous_end : match.start()], placeholder)) - previous_end = match.end() - protected_sql_parts.append(sql[previous_end:]) - return "".join(protected_sql_parts), protected_identifiers - - -def _restore_protected_identifiers(*, sql: str, protected_identifiers: dict[str, str]) -> str: - restored_sql: str = sql - placeholder: str - identifier: str - for placeholder, identifier in protected_identifiers.items(): - restored_sql = restored_sql.replace(placeholder, identifier) - return restored_sql diff --git a/src/sqlbuild/executor/testing/_helpers/difference_samples.py b/src/sqlbuild/executor/testing/_helpers/difference_samples.py index 65f3d65704..24d1490128 100644 --- a/src/sqlbuild/executor/testing/_helpers/difference_samples.py +++ b/src/sqlbuild/executor/testing/_helpers/difference_samples.py @@ -3,27 +3,32 @@ from __future__ import annotations import logging -from collections import OrderedDict from dataclasses import replace -from typing import Any, Final +from typing import Any, Final, cast +import orjson + +import sqlbuild._native as _native from sqlbuild.adapter.contract.classes.base_adapter import BaseAdapter from sqlbuild.adapter.contract.types import TypeDialect +from sqlbuild.compiler.planner.main.execution.sql_test_dialect import ( + restore_sql_test_dialect_function_names, +) from sqlbuild.compiler.planner.models import ChainStep, SqlTestPlanEntry from sqlbuild.diagnostics.classes.diagnostic_record_redactor import DiagnosticRecordRedactor from sqlbuild.diagnostics.main.log_debug_event import log_debug_event -from sqlbuild.executor.testing._helpers.comparison_sql import ( - cte_definition_sql, - format_sql, - lift_preanalyzed_step_ctes, - lift_step_ctes, -) +from sqlbuild.executor.testing._helpers.native_requests import chain_step_request +from sqlbuild.executor.testing.exceptions import SqlTestRenderingError from sqlbuild.executor.testing.models import ( SqlTestColumnDifference, SqlTestDifferenceSample, StepResult, ) -from sqlbuild.executor.testing.types import SqlTestDifferenceDirection, SqlTestOutcome +from sqlbuild.executor.testing.types import ( + NativeSqlTestRenderingModule, + SqlTestDifferenceDirection, + SqlTestOutcome, +) _ROW_LIMIT: Final[int] = 3 _COLUMN_LIMIT: Final[int] = 12 @@ -121,55 +126,24 @@ def build_sql_test_difference_sample_sql( if step.expected_cte_sql is None: return "" - lifted_ctes: OrderedDict[str, str] = OrderedDict() - if step.lifted_ctes: - actual_sql, lifted_ctes = lift_preanalyzed_step_ctes( - sql=step.comparison_body_sql or step.resolved_sql, - complete_sql=step.resolved_sql, - preanalyzed_ctes=step.lifted_ctes, - lifted_ctes=lifted_ctes, - sql_analysis_enabled=test_entry.sql_analysis_enabled, - sql_analysis_dialect=sql_analysis_dialect, - ) - else: - actual_sql, lifted_ctes = lift_step_ctes( - sql=step.resolved_sql, - lifted_ctes=lifted_ctes, - sql_analysis_enabled=test_entry.sql_analysis_enabled, - sql_analysis_dialect=sql_analysis_dialect, + request: dict[str, object] = { + "step": chain_step_request(step=step), + "sqlAnalysisEnabled": test_entry.sql_analysis_enabled, + "setDifferenceOperator": set_difference_operator, + "sqlAnalysisDialect": sql_analysis_dialect, + "direction": direction.value, + "sampleLimit": sample_limit, + "useTopClause": sql_analysis_dialect == TypeDialect.TSQL, + } + response: object = orjson.loads( + cast(NativeSqlTestRenderingModule, _native).render_sql_test_difference_sample_json( + orjson.dumps(request, option=orjson.OPT_SORT_KEYS).decode() ) - expected_sql, lifted_ctes = lift_step_ctes( - sql=step.expected_cte_sql, - lifted_ctes=lifted_ctes, - sql_analysis_enabled=test_entry.sql_analysis_enabled, - sql_analysis_dialect=sql_analysis_dialect, - ) - cte_parts: list[str] = [ - cte_definition_sql(name=name, sql=sql) for name, sql in lifted_ctes.items() - ] - cte_parts.extend( - ( - cte_definition_sql(name="__actual", sql=actual_sql), - cte_definition_sql(name="__expected", sql=expected_sql), - ) - ) - is_unexpected: bool = direction == SqlTestDifferenceDirection.UNEXPECTED - left: str = "__actual" if is_unexpected else "__expected" - right: str = "__expected" if is_unexpected else "__actual" - bounded_select: str = ( - f"SELECT TOP {sample_limit} *" if sql_analysis_dialect == TypeDialect.TSQL else "SELECT *" - ) - limit_clause: str = "" if sql_analysis_dialect == TypeDialect.TSQL else f" LIMIT {sample_limit}" - sql: str = ( - f"WITH {', '.join(cte_parts)} {bounded_select} FROM (" - f"SELECT * FROM {left} {set_difference_operator} SELECT * FROM {right}" - f") AS __sqlbuild_difference{limit_clause}" - ) - return format_sql( - sql=sql, - sql_analysis_dialect=sql_analysis_dialect, - sql_analysis_enabled=test_entry.sql_analysis_enabled, ) + sql: object = response.get("sql") if isinstance(response, dict) else None + if not isinstance(sql, str): + raise SqlTestRenderingError("native SQL-test difference rendering returned invalid SQL") + return restore_sql_test_dialect_function_names(sql=sql, dialect=sql_analysis_dialect) def _fetch_difference_samples( diff --git a/src/sqlbuild/executor/testing/_helpers/expected_column_probe.py b/src/sqlbuild/executor/testing/_helpers/expected_column_probe.py new file mode 100644 index 0000000000..93671e5686 --- /dev/null +++ b/src/sqlbuild/executor/testing/_helpers/expected_column_probe.py @@ -0,0 +1,86 @@ +"""Explain failed comparisons caused by expected columns the model does not output.""" + +from __future__ import annotations + +import logging +from typing import Any, cast + +import orjson + +import sqlbuild._native as _native +from sqlbuild.adapter.contract.classes.base_adapter import BaseAdapter +from sqlbuild.compiler.planner.main.execution.sql_test_dialect import ( + restore_sql_test_dialect_function_names, +) +from sqlbuild.compiler.planner.models import SqlTestPlanEntry +from sqlbuild.diagnostics.classes.diagnostic_record_redactor import DiagnosticRecordRedactor +from sqlbuild.diagnostics.main.log_debug_event import log_debug_event +from sqlbuild.executor.testing._helpers.native_requests import comparison_request +from sqlbuild.executor.testing.main.missing_expected_columns import ( + describe_missing_expected_columns, +) +from sqlbuild.executor.testing.types import NativeSqlTestRenderingModule + +_LOGGER: logging.Logger = logging.getLogger("sqlbuild.execution") + + +def missing_expected_columns_message( + *, test_entry: SqlTestPlanEntry, adapter: BaseAdapter, connection: Any +) -> str | None: + """Probe actual step columns after a failed comparison and describe missing ones.""" + + for step_index, step in enumerate(test_entry.chain): + if step.expected_columns is None or step.expected_cte_sql is None: + continue + available_columns: frozenset[str] | None = _probe_actual_columns( + test_entry=test_entry, step_index=step_index, adapter=adapter, connection=connection + ) + if available_columns is None: + continue + message: str | None = describe_missing_expected_columns( + model_name=step.model_name, + expected_columns=step.expected_columns, + available_columns=available_columns, + ) + if message is not None: + return f"SQL test '{test_entry.name}': {message}" + return None + + +def _probe_actual_columns( + *, test_entry: SqlTestPlanEntry, step_index: int, adapter: BaseAdapter, connection: Any +) -> frozenset[str] | None: + request: dict[str, object] = comparison_request( + test_entry=test_entry, + set_difference_operator=adapter.render_set_difference_operator(), + sql_analysis_dialect=adapter.sql_analysis_dialect(), + ) + request["probeStepIndex"] = step_index + response: object = orjson.loads( + cast(NativeSqlTestRenderingModule, _native).render_sql_test_comparisons_json( + orjson.dumps({"requests": [request]}, option=orjson.OPT_SORT_KEYS).decode() + ) + ) + item: object = response[0] if isinstance(response, list) and response else None + sql: object = item.get("sql") if isinstance(item, dict) else None + if not isinstance(sql, str) or not sql: + return None + try: + cursor: Any = adapter.execute( + connection=connection, + sql=restore_sql_test_dialect_function_names( + sql=sql, dialect=adapter.sql_analysis_dialect() + ), + ) + description: Any | None = getattr(cursor, "description", None) + except Exception as error: + log_debug_event( + logger=_LOGGER, + message="SQL test actual column probe failed; preserving comparison error", + test_name=test_entry.name, + sqlbuild_error=DiagnosticRecordRedactor.text(str(error)), + ) + return None + if not description: + return None + return frozenset(str(column[0]).casefold() for column in description) diff --git a/src/sqlbuild/executor/testing/_helpers/native_requests.py b/src/sqlbuild/executor/testing/_helpers/native_requests.py new file mode 100644 index 0000000000..b78603822f --- /dev/null +++ b/src/sqlbuild/executor/testing/_helpers/native_requests.py @@ -0,0 +1,48 @@ +"""Serialize planned SQL-test steps for the native comparison renderer.""" + +from __future__ import annotations + +from sqlbuild.compiler.planner.models import ChainStep, SqlTestAssertionStep, SqlTestPlanEntry + + +def comparison_request( + *, + test_entry: SqlTestPlanEntry, + set_difference_operator: str, + sql_analysis_dialect: str | None, +) -> dict[str, object]: + """Serialize one planned test into a native comparison render request.""" + + return { + "chain": [chain_step_request(step=step) for step in test_entry.chain], + "assertions": [ + assertion_step_request(assertion=assertion) for assertion in test_entry.assertions + ], + "sqlAnalysisEnabled": test_entry.sql_analysis_enabled, + "setDifferenceOperator": set_difference_operator, + "sqlAnalysisDialect": sql_analysis_dialect, + } + + +def chain_step_request(*, step: ChainStep) -> dict[str, object]: + """Serialize one planned chain step for the native renderer.""" + + return { + "modelName": step.model_name, + "resolvedSql": step.resolved_sql, + "expectedCteSql": step.expected_cte_sql, + "liftedCtes": step.lifted_ctes, + "comparisonBodySql": step.comparison_body_sql, + "expectedColumns": step.expected_columns, + } + + +def assertion_step_request(*, assertion: SqlTestAssertionStep) -> dict[str, object]: + """Serialize one planned assertion step for the native renderer.""" + + return { + "name": assertion.name, + "resolvedSql": assertion.resolved_sql, + "liftedCtes": assertion.lifted_ctes, + "comparisonBodySql": assertion.comparison_body_sql, + } diff --git a/src/sqlbuild/executor/testing/constants.py b/src/sqlbuild/executor/testing/constants.py index d50f8efd45..26e967d345 100644 --- a/src/sqlbuild/executor/testing/constants.py +++ b/src/sqlbuild/executor/testing/constants.py @@ -4,5 +4,7 @@ SQL_TEST_EXECUTION_ERROR_CODE: str = "T002" SQL_TEST_ASSERTION_FAILED_CODE: str = "T003" SQL_TEST_ENTRY_MISSING_CODE: str = "T004" +SQL_TEST_EXPECTED_COLUMNS_CODE: str = "T005" -SQL_TEST_BACKTICK_IDENTIFIER_QUOTE: str = "`" +SQL_TEST_NATIVE_RENDER_WORKERS: int = 4 +SQL_TEST_IDENTIFIER_QUOTES: dict[str, str] = {'"': '"', "`": "`", "[": "]"} diff --git a/src/sqlbuild/executor/testing/main/_execute.py b/src/sqlbuild/executor/testing/main/_execute.py index f5b22dbfd2..0847bb3c33 100644 --- a/src/sqlbuild/executor/testing/main/_execute.py +++ b/src/sqlbuild/executor/testing/main/_execute.py @@ -7,9 +7,13 @@ from sqlbuild.adapter.contract.classes.base_adapter import BaseAdapter from sqlbuild.compiler.planner.models import SqlTestPlanEntry from sqlbuild.executor.testing._helpers.difference_samples import add_difference_samples +from sqlbuild.executor.testing._helpers.expected_column_probe import ( + missing_expected_columns_message, +) from sqlbuild.executor.testing.constants import ( SQL_TEST_ASSERTION_FAILED_CODE, SQL_TEST_EXECUTION_ERROR_CODE, + SQL_TEST_EXPECTED_COLUMNS_CODE, SQL_TEST_TOO_LARGE_CODE, ) from sqlbuild.executor.testing.main._sql_length import ( @@ -95,8 +99,16 @@ def execute_sql_test( connection=connection, ) except Exception as error: - lifecycle.failed(error=error, error_code=SQL_TEST_EXECUTION_ERROR_CODE) - error_message = ( + expected_columns_message: str | None = missing_expected_columns_message( + test_entry=test_entry, adapter=adapter, connection=connection + ) + error_code: str = ( + SQL_TEST_EXECUTION_ERROR_CODE + if expected_columns_message is None + else SQL_TEST_EXPECTED_COLUMNS_CODE + ) + lifecycle.failed(error=error, error_code=error_code) + error_message = expected_columns_message or ( f"test '{test_entry.name}' encountered an execution error while running " f"'{error_model_name}': {error}" ) @@ -115,11 +127,11 @@ def execute_sql_test( StepResult( model_name=error_model_name, outcome=SqlTestOutcome.ERROR, - error_code=SQL_TEST_EXECUTION_ERROR_CODE, + error_code=error_code, error_message=error_message, ), ), - error_code=SQL_TEST_EXECUTION_ERROR_CODE, + error_code=error_code, error_message=error_message, ) diff --git a/src/sqlbuild/executor/testing/main/comparison_sql.py b/src/sqlbuild/executor/testing/main/comparison_sql.py index 152699eaa6..099f3c7613 100644 --- a/src/sqlbuild/executor/testing/main/comparison_sql.py +++ b/src/sqlbuild/executor/testing/main/comparison_sql.py @@ -2,16 +2,9 @@ from __future__ import annotations -from sqlbuild.compiler.planner.main.execution.sql_test_dialect import ( - restore_sql_test_dialect_function_names, -) from sqlbuild.compiler.planner.models import SqlTestPlanEntry -from sqlbuild.executor.testing._helpers.comparison_sql import ( - build_chain_comparison_parts, - cte_definition_sql, - lift_preanalyzed_step_ctes, - lift_step_ctes, - unique_cte_suffix, +from sqlbuild.executor.testing.main.comparison_sql_batch import ( + build_sql_test_comparison_sql_batch, ) @@ -23,64 +16,9 @@ def build_sql_test_comparison_sql( ) -> str: """Build the single SQL statement used to execute a SQL unit test.""" - if not test_entry.chain and not test_entry.assertions: - return "" - - lifted_ctes, comparison_ctes, select_parts, cte_name_counts = build_chain_comparison_parts( - test_entry=test_entry, + (comparison_sql,) = build_sql_test_comparison_sql_batch( + test_entries=(test_entry,), set_difference_operator=set_difference_operator, sql_analysis_dialect=sql_analysis_dialect, ) - assertion_index: int - for assertion_index, assertion in enumerate(test_entry.assertions, start=len(test_entry.chain)): - assertion_suffix: str - assertion_suffix, cte_name_counts = unique_cte_suffix( - model_name=assertion.name, - cte_name_counts=cte_name_counts, - ) - assertion_cte: str = f"__assert__{assertion_suffix}" - assertion_sql: str - if assertion.lifted_ctes: - assertion_sql, lifted_ctes = lift_preanalyzed_step_ctes( - sql=assertion.comparison_body_sql or assertion.resolved_sql, - complete_sql=assertion.resolved_sql, - preanalyzed_ctes=assertion.lifted_ctes, - lifted_ctes=lifted_ctes, - sql_analysis_enabled=test_entry.sql_analysis_enabled, - sql_analysis_dialect=sql_analysis_dialect, - ) - else: - assertion_sql, lifted_ctes = lift_step_ctes( - sql=assertion.resolved_sql, - lifted_ctes=lifted_ctes, - sql_analysis_enabled=test_entry.sql_analysis_enabled, - sql_analysis_dialect=sql_analysis_dialect, - ) - comparison_ctes.append(cte_definition_sql(name=assertion_cte, sql=assertion_sql)) - select_parts.append( - "SELECT " - f"{assertion_index} AS step_index, " - f"'assertion {_escape_sql_string(assertion.name)}' AS model_name, " - f"(SELECT COUNT(*) FROM {assertion_cte}) AS actual_count, " - "0 AS expected_count, " - f"(SELECT COUNT(*) FROM {assertion_cte}) AS unexpected_count, " - "0 AS missing_count" - ) - cte_parts: list[str] = [ - cte_definition_sql(name=name, sql=sql) for name, sql in lifted_ctes.items() - ] - cte_parts.extend(comparison_ctes) - if not select_parts: - return "" - comparison_sql: str = "WITH " + ",\n".join(cte_parts) + "\n" - comparison_sql += "\nUNION ALL\n".join(select_parts) - return restore_sql_test_dialect_function_names( - sql=comparison_sql, - dialect=sql_analysis_dialect, - ) - - -def _escape_sql_string(value: str) -> str: - """Escape a Python string for a single-quoted SQL string literal.""" - - return value.replace("'", "''") + return comparison_sql diff --git a/src/sqlbuild/executor/testing/main/comparison_sql_batch.py b/src/sqlbuild/executor/testing/main/comparison_sql_batch.py index 3a0d0a45c8..a1ea6ded17 100644 --- a/src/sqlbuild/executor/testing/main/comparison_sql_batch.py +++ b/src/sqlbuild/executor/testing/main/comparison_sql_batch.py @@ -1,15 +1,18 @@ -"""Build batches of executable SQL unit-test comparison queries.""" +"""Build batches of executable SQL unit-test comparison queries natively.""" from __future__ import annotations -import json from typing import Any, cast +import orjson + import sqlbuild._native as _native from sqlbuild.compiler.planner.main.execution.sql_test_dialect import ( restore_sql_test_dialect_function_names, ) from sqlbuild.compiler.planner.models import SqlTestPlanEntry +from sqlbuild.executor.testing._helpers.native_requests import comparison_request +from sqlbuild.executor.testing.constants import SQL_TEST_NATIVE_RENDER_WORKERS from sqlbuild.executor.testing.exceptions import SqlTestRenderingError from sqlbuild.executor.testing.types import NativeSqlTestRenderingModule @@ -24,41 +27,20 @@ def build_sql_test_comparison_sql_batch( if not test_entries: return () - requests: list[dict[str, object]] = [] - for test_entry in test_entries: - requests.append( - { - "chain": [ - { - "modelName": step.model_name, - "resolvedSql": step.resolved_sql, - "expectedCteSql": step.expected_cte_sql, - "liftedCtes": step.lifted_ctes, - "comparisonBodySql": step.comparison_body_sql, - } - for step in test_entry.chain - ], - "assertions": [ - { - "name": assertion.name, - "resolvedSql": assertion.resolved_sql, - "liftedCtes": assertion.lifted_ctes, - "comparisonBodySql": assertion.comparison_body_sql, - } - for assertion in test_entry.assertions - ], - "sqlAnalysisEnabled": test_entry.sql_analysis_enabled, - "setDifferenceOperator": set_difference_operator, - "sqlAnalysisDialect": sql_analysis_dialect, - } + requests: list[dict[str, object]] = [ + comparison_request( + test_entry=test_entry, + set_difference_operator=set_difference_operator, + sql_analysis_dialect=sql_analysis_dialect, ) - response: object = json.loads( + for test_entry in test_entries + ] + response: object = orjson.loads( cast(NativeSqlTestRenderingModule, _native).render_sql_test_comparisons_json( - json.dumps( - {"requests": requests, "workers": 4}, - sort_keys=True, - separators=(",", ":"), - ) + orjson.dumps( + {"requests": requests, "workers": SQL_TEST_NATIVE_RENDER_WORKERS}, + option=orjson.OPT_SORT_KEYS, + ).decode() ) ) if not isinstance(response, list) or len(response) != len(test_entries): diff --git a/src/sqlbuild/executor/testing/main/missing_expected_columns.py b/src/sqlbuild/executor/testing/main/missing_expected_columns.py new file mode 100644 index 0000000000..b8536007a2 --- /dev/null +++ b/src/sqlbuild/executor/testing/main/missing_expected_columns.py @@ -0,0 +1,34 @@ +"""Explain expected-output columns that a model does not produce.""" + +from __future__ import annotations + +from sqlbuild.executor.testing.constants import SQL_TEST_IDENTIFIER_QUOTES + + +def describe_missing_expected_columns( + *, + model_name: str, + expected_columns: tuple[str, ...], + available_columns: frozenset[str], +) -> str | None: + """Describe listed expected columns absent from casefolded model output names.""" + + missing: tuple[str, ...] = tuple( + column + for column in expected_columns + if _normalized_identifier(column) not in available_columns + ) + if not missing: + return None + return ( + f"expected output '{model_name}' lists columns that model '{model_name}' does not " + f"output: {', '.join(missing)}. Expected CTEs are compared only on the columns they " + "list, matched by name" + ) + + +def _normalized_identifier(identifier: str) -> str: + closing_quote: str | None = SQL_TEST_IDENTIFIER_QUOTES.get(identifier[:1]) + if closing_quote is None or not identifier.endswith(closing_quote): + return identifier.casefold() + return identifier[1:-1].replace(closing_quote * 2, closing_quote).casefold() diff --git a/src/sqlbuild/executor/testing/types.py b/src/sqlbuild/executor/testing/types.py index eb5bec6f21..3a1d96b2e7 100644 --- a/src/sqlbuild/executor/testing/types.py +++ b/src/sqlbuild/executor/testing/types.py @@ -13,6 +13,10 @@ def render_sql_test_comparisons_json(self, request_json: str) -> str: ... def plan_and_render_sql_tests_json(self, request_json: str) -> str: ... + def resolve_sql_test_chains_json(self, request_json: str) -> str: ... + + def render_sql_test_difference_sample_json(self, request_json: str) -> str: ... + class SqlTestOutcome(StrEnum): PASS = "pass" diff --git a/src/sqlbuild/runtime/observability/constants.py b/src/sqlbuild/runtime/observability/constants.py index ea1ddc81e9..980a7bb61e 100644 --- a/src/sqlbuild/runtime/observability/constants.py +++ b/src/sqlbuild/runtime/observability/constants.py @@ -121,6 +121,7 @@ "python_materialization", "python_task", "sql_hook", + "sql_test_planning", "scenario_capture", "scenario_cleanup", "scenario_execution", diff --git a/tests/e2e/src/sqlbuild/cli/commands/main/build/_test_types.py b/tests/e2e/src/sqlbuild/cli/commands/main/build/_test_types.py index 4ea7cd4ccd..7687fc89aa 100644 --- a/tests/e2e/src/sqlbuild/cli/commands/main/build/_test_types.py +++ b/tests/e2e/src/sqlbuild/cli/commands/main/build/_test_types.py @@ -1060,3 +1060,28 @@ class DynamicColumnContractE2ETestCase: description: str project_name: str expected_rows: tuple[tuple[object, ...], ...] + + +@dataclass(frozen=True) +class ReplayFullRebuildE2ETestCase: + """Test case for full-history replay of an existing incremental model.""" + + description: str + target_table: str + changed_model_path: str + changed_model_sql: str + rebuild_command: tuple[str, ...] + expected_plan_fragments: tuple[str, ...] + + +@dataclass(frozen=True) +class WindowLimitedReplayE2ETestCase: + """Test case for incremental runs that must stay window-limited.""" + + description: str + changed_model_sql: str + source_mutation_sql: str + expected_plan_fragments: tuple[str, ...] + changed_rows_sql: str + expected_changed_rows: int + expected_total_rows: int diff --git a/tests/e2e/src/sqlbuild/cli/commands/main/build/helpers.py b/tests/e2e/src/sqlbuild/cli/commands/main/build/helpers.py index e8a65aa593..1dbd92dbcd 100644 --- a/tests/e2e/src/sqlbuild/cli/commands/main/build/helpers.py +++ b/tests/e2e/src/sqlbuild/cli/commands/main/build/helpers.py @@ -19,6 +19,24 @@ ) +def run_replay_command( + *, command: tuple[str, ...], project_dir: Path +) -> subprocess.CompletedProcess[str]: + """Run a successful replay lifecycle command and retain its output.""" + result: subprocess.CompletedProcess[str] = run_sqb( + command=("--no-color", *command), project_dir=project_dir + ) + assert result.returncode == 0, result.stdout + result.stderr + return result + + +def replay_order_rows(*, db_path: Path, table: str) -> list[tuple[object, ...]]: + """Read complete replay results in deterministic order.""" + return query_duckdb( + db_path=db_path, sql=f"SELECT order_date, amount_cents FROM main.{table} ORDER BY 1" + ) + + def capped_microbatch_project_files( *, limit_action: str, project_limit: str = "" ) -> dict[str, str]: diff --git a/tests/e2e/src/sqlbuild/cli/commands/main/build/test_replay_on_change_full_build.py b/tests/e2e/src/sqlbuild/cli/commands/main/build/test_replay_on_change_full_build.py new file mode 100644 index 0000000000..f224bc1f82 --- /dev/null +++ b/tests/e2e/src/sqlbuild/cli/commands/main/build/test_replay_on_change_full_build.py @@ -0,0 +1,354 @@ +"""E2E tests for replay_on_change rebuilds of existing incremental models.""" + +from __future__ import annotations + +from pathlib import Path +from textwrap import dedent + +import pytest + +from tests.e2e.src.sqlbuild.cli.commands.main.build._test_types import ( + ReplayFullRebuildE2ETestCase, + WindowLimitedReplayE2ETestCase, +) +from tests.e2e.src.sqlbuild.cli.commands.main.build.helpers import ( + replay_order_rows, + run_replay_command, +) +from tests.e2e.src.sqlbuild.cli.commands.shared.helpers import ( + execute_duckdb, + prepare_inline_project, + query_duckdb, +) + +_SOURCE_ROWS_SQL: str = ( + "CREATE TABLE raw_orders AS " + "SELECT i AS order_id, TIMESTAMP '2026-01-01' + to_days(CAST(i - 1 AS INTEGER)) AS order_date, " + "100 + i AS amount_cents FROM range(1, 61) AS t(i)" +) + +_BASE_FILES: dict[str, str] = { + "sqlbuild_project.toml": dedent( + """ + name = "replay_full_project" + adapter = "duckdb" + + [connection] + database = "replay.duckdb" + """ + ).lstrip(), + "sources/raw.yml": dedent( + """ + sources: + - name: raw_orders + schema: main + table: raw_orders + """ + ).lstrip(), + "models/order_regions.sql": "MODEL (materialized table);\n\nSELECT 1 AS region_id\n", + "models/stg_orders.sql": dedent( + """ + MODEL ( + materialized incremental, + incremental_strategy delete_insert, + cursor order_date, + cursor_type timestamp, + cursor_grain day, + lookback 3d, + cursor_start "2026-01-01", + ); + + SELECT order_id, order_date, amount_cents + FROM __source("raw_orders") + """ + ).lstrip(), +} + +_DAILY_ORDERS_CONFIG: str = dedent( + """ + MODEL ( + materialized incremental, + incremental_strategy delete_insert, + cursor order_date, + cursor_type timestamp, + cursor_grain day, + lookback 4d, + cursor_inputs ( + stg_orders order_date, + ), + cursor_start "2026-01-01", + replay_on_change full, + ); + """ +).lstrip() + +_DAILY_ORDERS_SQL: str = ( + _DAILY_ORDERS_CONFIG + + "\nSELECT order_date, SUM(amount_cents) AS amount_cents\n" + + 'FROM __ref("stg_orders")\nGROUP BY order_date\n' +) + +_DAILY_ORDERS_WITH_UNLISTED_REF_SQL: str = ( + _DAILY_ORDERS_CONFIG + + "\nSELECT o.order_date, SUM(o.amount_cents) AS amount_cents\n" + + 'FROM __ref("stg_orders") AS o\n' + + 'JOIN __ref("order_regions") AS r ON r.region_id = 1\n' + + "GROUP BY o.order_date\n" +) + + +_SOURCE_ORDERS_SQL: str = dedent( + """ + MODEL ( + materialized incremental, + incremental_strategy delete_insert, + cursor order_date, + cursor_type timestamp, + cursor_grain day, + lookback 4d, + cursor_start "2026-01-01", + replay_on_change full, + ); + + SELECT order_date, amount_cents, {revision} AS revision + FROM __source("raw_orders") + """ +).lstrip() + + +_MICROBATCH_ORDERS_SQL: str = dedent( + """ + MODEL ( + materialized incremental, + incremental_strategy delete_insert, + incremental_mode microbatch, + microbatch_strategy watermark, + cursor_watermark_mode all, + batch_size 7d, + cursor order_date, + cursor_type timestamp, + cursor_grain day, + lookback 4d, + cursor_inputs ( + stg_orders (column order_date, roles [filter, watermark]), + ), + cursor_start "2026-01-01", + replay_on_change full, + ); + + SELECT order_date, SUM(amount_cents) AS amount_cents, {revision} AS revision + FROM __ref("stg_orders") + GROUP BY order_date + """ +).lstrip() + + +_WINDOWED_ORDERS_SQL: str = dedent( + """ + MODEL ( + materialized incremental, + incremental_strategy delete_insert, + cursor order_date, + cursor_type timestamp, + cursor_grain day, + lookback 4d, + cursor_start "2026-01-01", + replay_on_change bounded-3d, + ); + + SELECT order_date, amount_cents, {revision} AS revision + FROM __source("raw_orders") + """ +).lstrip() + + +_FULL_HISTORY_RANGE: str = "range 2026-01-01 \u2192 2026-03-01" + + +@pytest.mark.parametrize( + "test_case", + [ + ReplayFullRebuildE2ETestCase( + description="planner_resolved_model_backed_input_with_added_unlisted_ref", + target_table="daily_orders", + changed_model_path="models/daily_orders.sql", + changed_model_sql=_DAILY_ORDERS_WITH_UNLISTED_REF_SQL, + rebuild_command=("--select", "daily_orders"), + expected_plan_fragments=( + "daily_orders", + "full rebuild", + _FULL_HISTORY_RANGE, + "bounds planner-resolved", + "policy replay_on_change=full", + ), + ), + ReplayFullRebuildE2ETestCase( + description="runtime_owned_bounds_when_upstream_incremental_runs_in_same_build", + target_table="daily_orders", + changed_model_path="models/daily_orders.sql", + changed_model_sql=_DAILY_ORDERS_WITH_UNLISTED_REF_SQL, + rebuild_command=(), + expected_plan_fragments=( + "full rebuild", + "bounds runtime-owned (model-backed cursor input)", + "policy replay_on_change=full", + ), + ), + ReplayFullRebuildE2ETestCase( + description="query_change_without_cursor_inputs", + target_table="source_orders", + changed_model_path="models/source_orders.sql", + changed_model_sql=_SOURCE_ORDERS_SQL.format(revision=2), + rebuild_command=("--select", "source_orders"), + expected_plan_fragments=( + "full rebuild", + _FULL_HISTORY_RANGE, + "bounds planner-resolved", + ), + ), + ReplayFullRebuildE2ETestCase( + description="microbatch_model", + target_table="microbatch_orders", + changed_model_path="models/microbatch_orders.sql", + changed_model_sql=_MICROBATCH_ORDERS_SQL.format(revision=2), + rebuild_command=("--select", "microbatch_orders"), + expected_plan_fragments=( + "full rebuild", + _FULL_HISTORY_RANGE, + "batches 9 x 7d", + ), + ), + ], + ids=lambda case: case.description, +) +def test_given_existing_incremental_when_replay_on_change_full_rebuilds_then_history_matches_first_run( + test_case: ReplayFullRebuildE2ETestCase, + tmp_path: Path, +) -> None: + project_dir: Path = prepare_inline_project( + tmp_path=tmp_path, + project_name="replay_full_project", + repo_files={ + **_BASE_FILES, + "models/daily_orders.sql": _DAILY_ORDERS_SQL, + "models/source_orders.sql": _SOURCE_ORDERS_SQL.format(revision=1), + "models/microbatch_orders.sql": _MICROBATCH_ORDERS_SQL.format(revision=1), + }, + ) + db_path: Path = project_dir / "replay.duckdb" + execute_duckdb(db_path=db_path, sql=_SOURCE_ROWS_SQL) + run_replay_command(command=("build",), project_dir=project_dir) + first_run_rows: list[tuple[object, ...]] = replay_order_rows( + db_path=db_path, table=test_case.target_table + ) + assert len(first_run_rows) == 60 + + (project_dir / test_case.changed_model_path).write_text( + test_case.changed_model_sql, encoding="utf-8" + ) + plan_output: str = run_replay_command( + command=("plan", *test_case.rebuild_command), project_dir=project_dir + ).stdout + fragment: str + for fragment in test_case.expected_plan_fragments: + assert fragment in plan_output, plan_output + + run_replay_command(command=("build", *test_case.rebuild_command), project_dir=project_dir) + + assert replay_order_rows(db_path=db_path, table=test_case.target_table) == first_run_rows + + fresh_project_dir: Path = prepare_inline_project( + tmp_path=tmp_path, + project_name="fresh_replay_project", + repo_files={ + **_BASE_FILES, + "models/daily_orders.sql": _DAILY_ORDERS_SQL, + "models/source_orders.sql": _SOURCE_ORDERS_SQL.format(revision=1), + "models/microbatch_orders.sql": _MICROBATCH_ORDERS_SQL.format(revision=1), + test_case.changed_model_path: test_case.changed_model_sql, + }, + ) + fresh_db_path: Path = fresh_project_dir / "replay.duckdb" + execute_duckdb(db_path=fresh_db_path, sql=_SOURCE_ROWS_SQL) + run_replay_command(command=("build",), project_dir=fresh_project_dir) + + assert query_duckdb( + db_path=db_path, sql=f"SELECT * FROM main.{test_case.target_table} ORDER BY 1" + ) == query_duckdb( + db_path=fresh_db_path, sql=f"SELECT * FROM main.{test_case.target_table} ORDER BY 1" + ) + + +@pytest.mark.parametrize( + "test_case", + [ + WindowLimitedReplayE2ETestCase( + description="bounded_replay_rebuilds_only_bounded_window", + changed_model_sql=_WINDOWED_ORDERS_SQL.format(revision=2), + source_mutation_sql="SELECT 1", + expected_plan_fragments=( + "rebuild last 3d", + "range 2026-02-27 \u2192 2026-03-01", + "policy replay_on_change=bounded-3d", + ), + changed_rows_sql="SELECT COUNT(*) FROM main.windowed_orders WHERE revision = 2", + expected_changed_rows=3, + expected_total_rows=60, + ), + WindowLimitedReplayE2ETestCase( + description="unchanged_model_uses_incremental_lookback_window", + changed_model_sql=_WINDOWED_ORDERS_SQL.format(revision=1), + source_mutation_sql=( + "UPDATE raw_orders SET amount_cents = amount_cents + 1000; " + "INSERT INTO raw_orders VALUES " + "(61, TIMESTAMP '2026-03-02', 1161), (62, TIMESTAMP '2026-03-03', 1162)" + ), + expected_plan_fragments=("range 2026-02-25 \u2192 2026-03-03",), + changed_rows_sql=( + "SELECT COUNT(*) FROM main.windowed_orders WHERE amount_cents >= 1000" + ), + expected_changed_rows=7, + expected_total_rows=62, + ), + ], + ids=lambda case: case.description, +) +def test_given_window_limited_run_when_building_existing_incremental_then_only_window_is_replayed( + test_case: WindowLimitedReplayE2ETestCase, + tmp_path: Path, +) -> None: + project_dir: Path = prepare_inline_project( + tmp_path=tmp_path, + project_name="replay_window_project", + repo_files={ + **_BASE_FILES, + "models/windowed_orders.sql": _WINDOWED_ORDERS_SQL.format(revision=1), + }, + ) + db_path: Path = project_dir / "replay.duckdb" + execute_duckdb(db_path=db_path, sql=_SOURCE_ROWS_SQL) + run_replay_command(command=("build",), project_dir=project_dir) + + (project_dir / "models" / "windowed_orders.sql").write_text( + test_case.changed_model_sql, encoding="utf-8" + ) + execute_duckdb(db_path=db_path, sql=test_case.source_mutation_sql) + plan_output: str = run_replay_command( + command=("plan", "--select", "windowed_orders"), project_dir=project_dir + ).stdout + fragment: str + for fragment in test_case.expected_plan_fragments: + assert fragment in plan_output, plan_output + + run_replay_command(command=("build", "--select", "windowed_orders"), project_dir=project_dir) + + assert query_duckdb(db_path=db_path, sql=test_case.changed_rows_sql) == [ + (test_case.expected_changed_rows,) + ] + assert query_duckdb(db_path=db_path, sql="SELECT COUNT(*) FROM main.windowed_orders") == [ + (test_case.expected_total_rows,) + ] + + +if __name__ == "__main__": + pytest.main([__file__, "-vv"]) diff --git a/tests/e2e/src/sqlbuild/cli/commands/main/compile/helpers.py b/tests/e2e/src/sqlbuild/cli/commands/main/compile/helpers.py index d944ef3efa..f0cb744140 100644 --- a/tests/e2e/src/sqlbuild/cli/commands/main/compile/helpers.py +++ b/tests/e2e/src/sqlbuild/cli/commands/main/compile/helpers.py @@ -651,7 +651,7 @@ def _run_profiled_compile_benchmark( ] ) elapsed_seconds: float = time.perf_counter() - start - assert exit_code == 0 + assert exit_code == 0, output.getvalue() payload: dict[str, object] = json.loads(output.getvalue()) timings: object = payload["compile_timings"] summary: object = payload["summary"] @@ -1079,6 +1079,7 @@ def write_layered_production_compile_project( model_count=model_count, test_count=test_count, source_count=source_count, + seed_count=seed_count, ) @@ -1122,6 +1123,7 @@ def write_semantic_compile_project( test_count=test_count, source_count=source_count, semantic_fixture_scale=True, + seed_count=seed_count, ) @@ -1665,6 +1667,7 @@ def _layered_write_tests( model_count: int, test_count: int, source_count: int, + seed_count: int, semantic_fixture_scale: bool = False, ) -> None: tests_dir: Path = project_dir / "tests" / "unit" @@ -1680,6 +1683,7 @@ def _layered_write_tests( model_count=model_count, source_count=source_count, repeated_target_count=repeated_target_count, + seed_count=seed_count, semantic_fixture_scale=semantic_fixture_scale, ) for test_index in range( @@ -1696,6 +1700,7 @@ def _layered_test_block( model_count: int, source_count: int, repeated_target_count: int, + seed_count: int, semantic_fixture_scale: bool = False, ) -> str: group_count: int = (model_count - _SPINE_DEPTH) // _TEST_CHAIN_DEPTH @@ -1711,12 +1716,23 @@ def _layered_test_block( True: 5 + (test_index % 5) * 5, False: 40 + (test_index % 5) * 40, }[semantic_fixture_scale] + first_seed_index: int = max( + _SEED_REFERENCE_START_INDEX, base_index + int(not semantic_fixture_scale) + ) + first_seed_index = ((first_seed_index + _SEED_INTERVAL - 1) // _SEED_INTERVAL) * _SEED_INTERVAL + seed_indexes: set[int] = { + index % seed_count for index in range(first_seed_index, target_index + 1, _SEED_INTERVAL) + } + seed_fixtures: str = "".join( + f"__seed__seed_{index:05d} AS (SELECT 1 AS id),\n" for index in sorted(seed_indexes) + ) return _layered_test_sql( test_index=test_index, source_index=source_index, target_index=target_index, fixture_row_count=fixture_row_count, include_assertion=test_index % 7 == 0, + seed_fixtures=seed_fixtures, ) @@ -1727,6 +1743,7 @@ def _layered_test_sql( target_index: int, fixture_row_count: int, include_assertion: bool, + seed_fixtures: str, ) -> str: fixture_rows: str = " UNION ALL\n".join( f" SELECT {row} AS id, CAST({row} AS DOUBLE) AS amount, 'source' AS status" @@ -1740,7 +1757,7 @@ def _layered_test_sql( return f"""TEST (name "layered_production_case_{test_index:05d}"); WITH -__source__source_{source_index:05d} AS ( +{seed_fixtures}__source__source_{source_index:05d} AS ( {fixture_rows} ), __expected__model_{target_index:05d} AS ( diff --git a/tests/e2e/src/sqlbuild/cli/commands/main/compile/test_dense_compile_performance.py b/tests/e2e/src/sqlbuild/cli/commands/main/compile/test_dense_compile_performance.py index 5fc15dbfbc..d2f7b47db8 100644 --- a/tests/e2e/src/sqlbuild/cli/commands/main/compile/test_dense_compile_performance.py +++ b/tests/e2e/src/sqlbuild/cli/commands/main/compile/test_dense_compile_performance.py @@ -40,28 +40,28 @@ 1000, 14.0, 3 * _GIB // 2, - "24d355f005d1e4c027ad06bc80678e6dede7c3d6a55e5e9bcae3ba4cfc1de922", + "99f2f5c698cca3c7f0d5a2defbd31935d98813843c899286a23a8e7d4441a736", ), DenseCompileGuardTestCase( "dense_models_3000_all_rules", 3000, 34.0, 11 * _GIB // 4, - "554dbb7e4aa130abc1e8801482aff123a746d382ed1f5b70d5c0d80fe2559edb", + "1243f3de95857de4e7d1d8563ae6766aa9d2a192cf7525cec8ef5562b12704dc", ), DenseCompileGuardTestCase( "dense_models_5000_all_rules", 5000, 55.0, 13 * _GIB // 4, - "79013b434bc8efdc0a6ebbcc6e23af24ba4b459a6f2ca99fcdd75fd9f5042e85", + "956135597584ec369d37c23ab91b01bc6bf2df9ba78dce602311e152d451fa90", ), DenseCompileGuardTestCase( "dense_models_10000_all_rules", 10000, 145.0, 4 * _GIB, - "8125ddde6d3ae172bb15878f95048d66398453c516bbd837b1fd83c04c5982b5", + "92bf43fc45084c67de0eab3d4f32160e8303f99e1b951f6a7a1da5660f0eb66b", ), ), ids=lambda case: case.description, diff --git a/tests/e2e/src/sqlbuild/cli/commands/main/compile/test_fresh_process_compile_cache_performance.py b/tests/e2e/src/sqlbuild/cli/commands/main/compile/test_fresh_process_compile_cache_performance.py index 265738965a..f586c285ae 100644 --- a/tests/e2e/src/sqlbuild/cli/commands/main/compile/test_fresh_process_compile_cache_performance.py +++ b/tests/e2e/src/sqlbuild/cli/commands/main/compile/test_fresh_process_compile_cache_performance.py @@ -51,16 +51,16 @@ expected_max_rss_bytes=2 * _GIB, expected_max_cache_bytes=96 * _MIB, expected_cold_fingerprint=( - "36e2274af2bd6840f41d88ee1140a91034505a50f7fa27b61e2e47aa06765314" + "3a955baeac3aaef85d1bcfe25eddce899abf8e542ad168510faa8c6cb53b9a30" ), expected_leaf_edit_fingerprint=( - "f6cfcbb21bf7e011b76f08f5d8e61b2095316b6845d987090e79b1718ed54175" + "37171d29e89f3ed9db9b29b21d9af810a7c9bf3555cc497159c2d4ef87ae8f9d" ), expected_macro_edit_fingerprint=( - "ae102976a43c0fd6a68e5e55a7feeb9fac5f49ffc56bd3cdfcd389e199582081" + "e37d388dc19e21da66fcb2f6fad2c3ef8db288724e7ab65031a75180e1535949" ), expected_project_config_fingerprint=( - "96e996a556119f452e966096747b35219941bf7bd324624242aadf6375ba1a31" + "cbbe850cd2288979fd7682180176dfa9cda00bb48968ddb3b6c16ecd5728a389" ), macro_call_interval=6, scoped_macros=True, @@ -83,16 +83,16 @@ expected_max_rss_bytes=2 * _GIB, expected_max_cache_bytes=160 * _MIB, expected_cold_fingerprint=( - "686664bcadbbb0e339205d71b8fb3ab2d3f1226ca888eb4b2e6a5aa97ade1efb" + "b39d33782ca1bc09235a6b6190dbc6a35cec43a161ef29d459aafd3f6be90166" ), expected_leaf_edit_fingerprint=( - "43fa1db2d5db12c5031aef8f6fb2a0e4af89c51ece58b1f1772db0a9ea4d43a0" + "1bbf19080469c9716003c718fad5f8c196619778d20a44a4464a0af1c9f291d6" ), expected_macro_edit_fingerprint=( - "fd0cc80586b11d8d79a904d1daa6da4d559d22d6b4ebea02139a7d12626b770f" + "394e5ee7d30c4cccbf608ea9d0b0e3a37d81b3948804d83a867244eccd9f3135" ), expected_project_config_fingerprint=( - "44a292e9ada77cc7038036fb593d9d8d8cd4883062a07254d963963ed0b4bb45" + "4e2e86138b95fab8d4e45f97a990984f5057ad4686c4e05f0ba1de2de2ebe280" ), macro_call_interval=6, scoped_macros=True, @@ -115,16 +115,16 @@ expected_max_rss_bytes=2 * _GIB, expected_max_cache_bytes=320 * _MIB, expected_cold_fingerprint=( - "c1364cae404ee768c4443c3b964c42370e33268fbf83b101890b13558290e3e0" + "1e3a63aab6d47bd1425c23d6f03c084d5207086caad6ce0da7564be0ce048158" ), expected_leaf_edit_fingerprint=( - "fe35feb1b239a17ed0e7b22bca20c2af06e0f25335fe5f9fae212491a693911b" + "a853c671586612047923ffd1b8f0a5ef9d9a7c2d6edb634ad731432e5b641b16" ), expected_macro_edit_fingerprint=( - "bec17e83316ca926b76f450fa8a035e83f19c061e14a11b8effe04b7fa2e26fd" + "25793047e0ae59c5c9d3a67ace435990de502e0d2c2be9d25ba2b5589ecf778d" ), expected_project_config_fingerprint=( - "780e26aa973459fbc803dd8d64315d445aab8bc57e90b0ad1ecb94f074b22474" + "1cd1b9d836d9f5dd27c632cc8d9ab68464855dc22457c68ed44ad7efa7eb44af" ), macro_call_interval=6, scoped_macros=True, @@ -174,6 +174,7 @@ def test_given_semantic_project_when_compiling_across_processes_then_cache_is_in measurement.semantic_fingerprint, fresh_process_compile_cache_metrics(measurement), ) + for measurement in measurements.values(): assert_successful_compile_cache_payload(measurement=measurement, test_case=test_case) assert measurement.peak_rss_bytes < test_case.expected_max_rss_bytes diff --git a/tests/e2e/src/sqlbuild/cli/commands/main/compile/test_fresh_process_compile_performance.py b/tests/e2e/src/sqlbuild/cli/commands/main/compile/test_fresh_process_compile_performance.py index 065d48883d..057d8a1af4 100644 --- a/tests/e2e/src/sqlbuild/cli/commands/main/compile/test_fresh_process_compile_performance.py +++ b/tests/e2e/src/sqlbuild/cli/commands/main/compile/test_fresh_process_compile_performance.py @@ -39,7 +39,7 @@ expected_max_wall_seconds=11.0, expected_max_rss_bytes=int(1.5 * _GIB), expected_semantic_fingerprint=( - "6841a94fc2d3a740cb0db14fe6cee46bfe32171b9580949134cd40379a31bf94" + "e31803bd257d02a76188a9e6d3bf3b08feb50395c48f01f5535120041c58c875" ), ), FreshProcessCompilePerformanceGuardTestCase( @@ -54,7 +54,7 @@ expected_max_wall_seconds=15.5, expected_max_rss_bytes=int(1.75 * _GIB), expected_semantic_fingerprint=( - "32dac15ee9c15a2aea2e4783e888231e402f15a8fe83270ef55af1aa75924b6c" + "144f480668acba715d02ecca6712397150b5f35280a963a099b7f0cc814dbe0f" ), ), FreshProcessCompilePerformanceGuardTestCase( @@ -69,7 +69,7 @@ expected_max_wall_seconds=29.5, expected_max_rss_bytes=2 * _GIB, expected_semantic_fingerprint=( - "7e1410c35f6026c7520711224aea2a95d8fe46515a2d2d370a3ee21180a36fdc" + "89fea6392fbd130552781d84f8b982037a56edef6b98e4345c9769e57c7e56a7" ), ), ], diff --git a/tests/e2e/src/sqlbuild/cli/commands/main/compile/test_varied_compile_cache_performance.py b/tests/e2e/src/sqlbuild/cli/commands/main/compile/test_varied_compile_cache_performance.py index 275fc1b114..572b6a61d2 100644 --- a/tests/e2e/src/sqlbuild/cli/commands/main/compile/test_varied_compile_cache_performance.py +++ b/tests/e2e/src/sqlbuild/cli/commands/main/compile/test_varied_compile_cache_performance.py @@ -44,10 +44,10 @@ 150, 250, ( - "1c107624785e798ad43bc271ce90f60ed62ed703fe01a240687dec54b8080e15", - "525245fb8374d566c8ff7a111fb0635abdc29f6ae25faba2a0f8b93beeeef8cc", - "2e27ca67a51cbb0672794518ab611ab21a1730b294d7a0252e72d6ca6322fe5b", - "be3f058013b62f2b0d54d6a1d52f7232d12c5c36641d801003ad8fda0b46e11f", + "de60ac45bddfc86fc9726e49514315d3a2945df949b2be9e8fc4cd835bc34200", + "c8cda76a18f96c345fbcbe22f05982c61f5837d4577fd7f2b45559b115756494", + "a107585c3754afc00d7119196a1421168d39d0336df64a41c89f400453591c3a", + "80d33bb0d3c0710e761658e344bfa895a5c351991eb4c8e94f1f7df7e85b4650", ), ), ), diff --git a/tests/e2e/src/sqlbuild/cli/commands/main/lineage/_test_types.py b/tests/e2e/src/sqlbuild/cli/commands/main/lineage/_test_types.py index fe92e067a0..1823d74eb2 100644 --- a/tests/e2e/src/sqlbuild/cli/commands/main/lineage/_test_types.py +++ b/tests/e2e/src/sqlbuild/cli/commands/main/lineage/_test_types.py @@ -24,3 +24,10 @@ class ColumnLineageCacheCliTestCase: description: str command: tuple[str, ...] expected_source_resource: str + + +@dataclass(frozen=True) +class LineageErrorCliTestCase: + description: str + command: tuple[str, ...] + expected_fragments: tuple[str, ...] diff --git a/tests/e2e/src/sqlbuild/cli/commands/main/lineage/test_lineage.py b/tests/e2e/src/sqlbuild/cli/commands/main/lineage/test_lineage.py index 95eed7256d..4a12f0a416 100644 --- a/tests/e2e/src/sqlbuild/cli/commands/main/lineage/test_lineage.py +++ b/tests/e2e/src/sqlbuild/cli/commands/main/lineage/test_lineage.py @@ -10,6 +10,7 @@ ColumnLineageCacheCliTestCase, LineageCacheCliTestCase, LineageCliTestCase, + LineageErrorCliTestCase, ) from tests.e2e.src.sqlbuild.cli.commands.main.lineage.helpers import ( lineage_node_ids, @@ -23,6 +24,89 @@ _LINEAGE_CACHE_RELATIVE_PATH: Path = Path("target/cache/lineage/v1/structural-graph.sqlite3") +@pytest.mark.parametrize( + "test_case", + ( + LineageCliTestCase( + description="directional expansion preserves exclusions", + command=( + "lineage", + "--select", + "fact_orders", + "--exclude", + "stg_orders", + "--direction", + "upstream", + "--depth", + "1", + "--format", + "json", + ), + expected_exit_code=0, + expected_node_ids=("model:fact_orders",), + expected_edge_ids=(), + ), + LineageCliTestCase( + description="tag selections expand with depth", + command=( + "lineage", + "--select", + "tag:finance", + "--direction", + "upstream", + "--depth", + "1", + "--format", + "json", + ), + expected_exit_code=0, + expected_node_ids=("model:fact_orders", "model:stg_orders"), + expected_edge_ids=("model:stg_orders->model:fact_orders",), + ), + LineageCliTestCase( + description="path selections expand with depth and tag exclusions", + command=( + "lineage", + "--select", + "path:staging", + "--exclude", + "tag:finance", + "--direction", + "downstream", + "--depth", + "1", + "--format", + "json", + ), + expected_exit_code=0, + expected_node_ids=("model:stg_customers", "model:stg_orders"), + expected_edge_ids=(), + ), + ), + ids=lambda case: case.description, +) +def test_given_directional_selectors_when_expanding_then_depth_and_exclusions_are_respected( + test_case: LineageCliTestCase, tmp_path: Path +) -> None: + project_dir: Path = prepare_lineage_cache_project(tmp_path=tmp_path) + staging_dir: Path = project_dir / "models" / "staging" + staging_dir.mkdir() + for name in ("stg_orders.sql", "stg_customers.sql"): + (project_dir / "models" / name).rename(staging_dir / name) + (project_dir / "models" / "fact_orders.sql").write_text( + 'MODEL (materialized view, tags [finance]);\nSELECT * FROM __ref("stg_orders")\n', + encoding="utf-8", + ) + + result: subprocess.CompletedProcess[str] = run_sqb( + command=test_case.command, project_dir=project_dir + ) + + assert result.returncode == test_case.expected_exit_code, result.stdout + result.stderr + payload: dict[str, object] = json.loads(result.stdout) + assert lineage_node_ids(payload=payload) == test_case.expected_node_ids + + @pytest.mark.parametrize( "test_case", [ @@ -74,6 +158,91 @@ "model:fact_orders->model:hourly_order_activity", ), ), + LineageCliTestCase( + description="unions direct parents of several targets", + command=( + "lineage", + "fact_orders", + "dim_customers", + "--direction", + "upstream", + "--depth", + "1", + "--format", + "json", + ), + expected_exit_code=0, + expected_node_ids=( + "model:dim_customers", + "model:fact_orders", + "model:stg_customers", + "model:stg_orders", + "model:stg_payments", + "seed:waffle_types", + "udf:is_completed_order", + "udf:is_completed_order_py", + ), + expected_edge_ids=( + "model:stg_customers->model:dim_customers", + "model:stg_orders->model:dim_customers", + "model:stg_payments->model:dim_customers", + "udf:is_completed_order->model:fact_orders", + "udf:is_completed_order_py->model:fact_orders", + "model:stg_orders->model:fact_orders", + "seed:waffle_types->model:fact_orders", + "model:stg_payments->model:fact_orders", + ), + ), + LineageCliTestCase( + description="accepts a printed kind-prefixed target", + command=("lineage", "model:fact_orders", "--depth", "1", "--format", "json"), + expected_exit_code=0, + expected_node_ids=( + "model:fact_orders", + "model:stg_orders", + "model:stg_payments", + "seed:waffle_types", + "udf:is_completed_order", + "udf:is_completed_order_py", + ), + expected_edge_ids=( + "udf:is_completed_order->model:fact_orders", + "udf:is_completed_order_py->model:fact_orders", + "model:stg_orders->model:fact_orders", + "seed:waffle_types->model:fact_orders", + "model:stg_payments->model:fact_orders", + ), + ), + LineageCliTestCase( + description="expands a selection by direction and depth", + command=( + "lineage", + "--select", + "stg_orders", + "--direction", + "downstream", + "--depth", + "1", + "--format", + "json", + ), + expected_exit_code=0, + expected_node_ids=( + "model:daily_order_partitioned", + "model:daily_revenue", + "model:dim_customers", + "model:fact_orders", + "model:scenario_order_prices", + "model:stg_orders", + ), + expected_edge_ids=( + "model:stg_orders->model:daily_order_partitioned", + "model:stg_orders->model:daily_revenue", + "model:stg_orders->model:dim_customers", + "model:stg_orders->model:fact_orders", + "model:stg_orders->model:scenario_order_prices", + ), + ), ], ids=lambda case: case.description, ) @@ -233,3 +402,40 @@ def test_given_column_target_when_lineage_runs_then_bypasses_structural_cache( source: dict[str, object] = trace[0]["source"] # type: ignore[assignment] assert source["resource_name"] == test_case.expected_source_resource assert not (project_dir / _LINEAGE_CACHE_RELATIVE_PATH).exists() + + +@pytest.mark.parametrize( + "test_case", + ( + LineageErrorCliTestCase( + description="graph operator target explains direction and depth", + command=("lineage", "1+fact_orders"), + expected_fragments=("C305", "--direction", "--depth"), + ), + LineageErrorCliTestCase( + description="kind prefix must match the resource", + command=("lineage", "source:fact_orders"), + expected_fragments=("C305", "source:fact_orders"), + ), + LineageErrorCliTestCase( + description="column target cannot be combined with other targets", + command=("lineage", "fact_orders.order_id", "dim_customers"), + expected_fragments=("C320",), + ), + ), + ids=lambda case: case.description, +) +def test_given_invalid_lineage_target_when_running_then_explains_the_error( + test_case: LineageErrorCliTestCase, + tmp_path: Path, +) -> None: + project_dir: Path = prepare_waffle_shop(tmp_path) + + result: subprocess.CompletedProcess[str] = run_sqb( + command=test_case.command, + project_dir=project_dir, + ) + + assert result.returncode == 1, result.stdout + result.stderr + for fragment in test_case.expected_fragments: + assert fragment in result.stderr, result.stderr diff --git a/tests/e2e/src/sqlbuild/cli/commands/main/test/_test_types.py b/tests/e2e/src/sqlbuild/cli/commands/main/test/_test_types.py index 2530b99812..e1cc71bf57 100644 --- a/tests/e2e/src/sqlbuild/cli/commands/main/test/_test_types.py +++ b/tests/e2e/src/sqlbuild/cli/commands/main/test/_test_types.py @@ -45,6 +45,7 @@ class SqlTestPlanInspectionE2ETestCase: repo_files: dict[str, str] expected_stdout_fragments: tuple[str, ...] expected_exit_code: int = 0 + unexpected_stdout_fragments: tuple[str, ...] = () @dataclass(frozen=True) @@ -112,3 +113,46 @@ class UnknownTableFunctionFixtureE2ETestCase: description: str expected_stderr_fragment: str + + +@dataclass(frozen=True) +class SharedGraphChainE2ETestCase: + """Test case for chained SQL tests over a shared (diamond) upstream graph.""" + + description: str + sql_analysis_enabled: bool + expected_stdout_fragments: tuple[str, ...] + once_rendered_fragments: tuple[str, ...] + + +@dataclass(frozen=True) +class SharedGraphMissingMockE2ETestCase: + """Test case for one missing mock reached on every path of a deep shared graph.""" + + description: str + layers: int + expected_error: str + expected_stdout_fragments: tuple[str, ...] + + +@dataclass(frozen=True) +class ExpectedColumnSubsetE2ETestCase: + """Test case for SQL tests that compare only the expected CTE's listed columns.""" + + description: str + sql_analysis_enabled: bool + command: tuple[str, ...] + expected_tests: dict[str, str] + expected_exit_code: int + expected_output_fragments: tuple[str, ...] + unexpected_output_fragments: tuple[str, ...] = () + + +@dataclass(frozen=True) +class MissingMockCliE2ETestCase: + """Test case for planning errors reported by plain test and compile commands.""" + + description: str + expected_error: str + compile_error_line: str + compile_summary_fragment: str diff --git a/tests/e2e/src/sqlbuild/cli/commands/main/test/helpers.py b/tests/e2e/src/sqlbuild/cli/commands/main/test/helpers.py index ddb554cb7a..19f394a14f 100644 --- a/tests/e2e/src/sqlbuild/cli/commands/main/test/helpers.py +++ b/tests/e2e/src/sqlbuild/cli/commands/main/test/helpers.py @@ -225,6 +225,18 @@ def build_mock_boundary_test_project_files() -> dict[str, str]: } +MOCKED_UNSATISFIED_LEAF_TEST_SQL: str = ( + "TEST();\n\n" + "WITH\n" + "__source__raw AS (SELECT 1 AS id),\n" + "__expected__fact_orders AS (\n" + " SELECT 1 AS id, 1 AS adjusted, 'CA' AS country, " + "'real' AS literal_text, 'active' AS status\n" + ")\n" + "SELECT 1\n" +) + + def build_unsatisfied_leaf_test_project_files() -> dict[str, str]: """Build a test that omits the source mock required by its real model chain.""" @@ -1015,3 +1027,161 @@ def build_macro_test_project_files() -> dict[str, str]: "SELECT 1\n" ), } + + +def build_diamond_chain_test_project_files(*, sql_analysis_enabled: bool) -> dict[str, str]: + """Build a shared (diamond) upstream graph with one passing and one failing chain test.""" + + sql_analysis_value: str = {False: "false", True: "true"}[sql_analysis_enabled] + return { + "sqlbuild_project.toml": ( + 'name = "diamond_demo"\n' + 'adapter = "duckdb"\n\n' + "[connection]\n" + 'database = "diamond_demo.duckdb"\n\n' + "[settings]\n" + f"sql_analysis = {sql_analysis_value}\n\n" + "[defaults]\n" + 'materialized = "table"\n' + ), + "sources/raw.yml": ( + "sources:\n - name: raw_orders\n schema: main\n table: raw_orders\n" + ), + "models/stg_orders.sql": ( + "MODEL (materialized table);\n\n" + 'SELECT id AS order_id, customer_id, amount FROM __source("raw_orders")\n' + ), + "models/large_orders.sql": ( + "MODEL (materialized table);\n\n" + 'SELECT order_id, customer_id, amount FROM __ref("stg_orders") WHERE amount >= 10\n' + ), + "models/small_orders.sql": ( + "MODEL (materialized table);\n\n" + "WITH picked AS (\n" + ' SELECT order_id, customer_id, amount FROM __ref("stg_orders") WHERE amount < 10\n' + ")\n" + "SELECT * FROM picked -- small orders only\n" + ), + "models/customer_order_mix.sql": ( + "MODEL (materialized table);\n\n" + "SELECT customer_id,\n" + ' (SELECT COUNT(*) FROM __ref("large_orders") AS l\n' + " WHERE l.customer_id = c.customer_id) AS large_count,\n" + ' (SELECT COUNT(*) FROM __ref("small_orders") AS s\n' + " WHERE s.customer_id = c.customer_id) AS small_count\n" + 'FROM (SELECT DISTINCT customer_id FROM __ref("stg_orders")) AS c\n' + ), + "tests/unit/test_customer_order_mix.sql": ( + 'TEST (name "customer_order_mix_matches");\n\n' + "WITH\n" + "__source__raw_orders AS (\n" + " SELECT 1 AS id, 1 AS customer_id, 5 AS amount\n" + " UNION ALL SELECT 2 AS id, 1 AS customer_id, 20 AS amount\n" + " UNION ALL SELECT 3 AS id, 2 AS customer_id, 30 AS amount\n" + "),\n" + "__expected__customer_order_mix AS (\n" + " SELECT 1 AS customer_id, 1 AS large_count, 1 AS small_count\n" + " UNION ALL SELECT 2 AS customer_id, 1 AS large_count, 0 AS small_count\n" + "),\n" + "__assert__no_negative_large_orders AS (\n" + ' SELECT * FROM __ref("large_orders") WHERE amount < 0\n' + ")\n" + "SELECT 1\n" + ), + "tests/unit/test_customer_order_mix_wrong.sql": ( + 'TEST (name "customer_order_mix_wrong");\n\n' + "WITH\n" + "__source__raw_orders AS (SELECT 1 AS id, 1 AS customer_id, 5 AS amount),\n" + "__expected__customer_order_mix AS (\n" + " SELECT 1 AS customer_id, 3 AS large_count, 1 AS small_count\n" + ")\n" + "SELECT 1\n" + ), + } + + +def build_deep_shared_missing_mock_project_files(*, layers: int) -> dict[str, str]: + """Build a deep diamond graph whose test omits one source mock reached on every path.""" + + files: dict[str, str] = { + "sqlbuild_project.toml": ( + 'name = "deep_demo"\n' + 'adapter = "duckdb"\n\n' + "[connection]\n" + 'database = "deep_demo.duckdb"\n\n' + "[defaults]\n" + 'materialized = "table"\n' + ), + "sources/raw.yml": ( + "sources:\n" + " - name: raw_orders\n schema: main\n table: raw_orders\n" + " - name: raw_customers\n schema: main\n table: raw_customers\n" + ), + "models/orders_00_left.sql": ( + "MODEL (materialized table);\n\n" + "SELECT o.order_id, o.amount\n" + 'FROM __source("raw_orders") AS o\n' + 'JOIN __source("raw_customers") AS c ON c.customer_id = o.customer_id\n' + ), + "models/orders_00_right.sql": ( + 'MODEL (materialized table);\n\nSELECT order_id, amount FROM __ref("orders_00_left")\n' + ), + } + for layer in range(1, layers + 1): + previous_left: str = f"orders_{layer - 1:02d}_left" + previous_right: str = f"orders_{layer - 1:02d}_right" + for side in ("left", "right"): + files[f"models/orders_{layer:02d}_{side}.sql"] = ( + "MODEL (materialized table);\n\n" + "SELECT a.order_id, a.amount + b.amount AS amount\n" + f'FROM __ref("{previous_left}") AS a\n' + f'JOIN __ref("{previous_right}") AS b ON a.order_id = b.order_id\n' + ) + files["tests/unit/test_deep_orders.sql"] = ( + 'TEST (name "deep_orders_missing_mock");\n\n' + "WITH\n" + "__source__raw_customers AS (SELECT 1 AS customer_id),\n" + f"__expected__orders_{layers:02d}_left AS (SELECT 1 AS order_id, 1 AS amount)\n" + "SELECT 1\n" + ) + return files + + +def build_expected_column_subset_project_files( + *, expected_tests: dict[str, str], sql_analysis_enabled: bool = True +) -> dict[str, str]: + """Build an orders model whose SQL tests list a chosen subset of its output columns.""" + + sql_analysis_value: str = {False: "false", True: "true"}[sql_analysis_enabled] + files: dict[str, str] = { + "sqlbuild_project.toml": ( + 'name = "expected_columns_demo"\n' + 'adapter = "duckdb"\n\n' + "[connection]\n" + 'database = "expected_columns_demo.duckdb"\n\n' + "[settings]\n" + f"sql_analysis = {sql_analysis_value}\n\n" + "[defaults]\n" + 'materialized = "table"\n' + ), + "sources/raw.yml": ( + "sources:\n - name: raw_orders\n schema: main\n table: raw_orders\n" + ), + "models/orders.sql": ( + "MODEL (materialized table);\n\n" + "SELECT id AS order_id, customer_id, status, amount * 2 AS amount\n" + 'FROM __source("raw_orders")\n' + ), + } + for test_name, expected_sql in expected_tests.items(): + files[f"tests/unit/{test_name}.sql"] = ( + f'TEST (name "{test_name}");\n\n' + "WITH\n" + "__source__raw_orders AS (\n" + " SELECT 1 AS id, 100 AS customer_id, 'paid' AS status, 5 AS amount\n" + " UNION ALL SELECT 2 AS id, 200 AS customer_id, 'open' AS status, 7 AS amount\n" + "),\n" + f"__expected__orders AS (\n {expected_sql}\n)\n" + "SELECT 1\n" + ) + return files diff --git a/tests/e2e/src/sqlbuild/cli/commands/main/test/test_test.py b/tests/e2e/src/sqlbuild/cli/commands/main/test/test_test.py index 331cb0a244..2ca60ccc35 100644 --- a/tests/e2e/src/sqlbuild/cli/commands/main/test/test_test.py +++ b/tests/e2e/src/sqlbuild/cli/commands/main/test/test_test.py @@ -2,16 +2,22 @@ from __future__ import annotations +import json import subprocess from pathlib import Path +from typing import Any import pytest from tests.e2e.src.sqlbuild.cli.commands.main.test._test_types import ( ComplexValuesFixtureE2ETestCase, + ExpectedColumnSubsetE2ETestCase, FixtureCompatibilityE2ETestCase, + MissingMockCliE2ETestCase, ParameterCaseSelectionE2ETestCase, PartialFixtureE2ETestCase, + SharedGraphChainE2ETestCase, + SharedGraphMissingMockE2ETestCase, SqlAnalysisChainSqlTestE2ETestCase, SqlTestE2ETestCase, SqlTestFixtureValidationE2ETestCase, @@ -21,6 +27,7 @@ UnknownTableFunctionFixtureE2ETestCase, ) from tests.e2e.src.sqlbuild.cli.commands.main.test.helpers import ( + MOCKED_UNSATISFIED_LEAF_TEST_SQL, build_assertion_test_project_files, build_chain_test_project_files, build_clause_partial_source_fixture_project_files, @@ -28,8 +35,11 @@ build_contract_empty_fixture_project_files, build_cte_derived_output_fixture_project_files, build_cte_partial_source_fixture_project_files, + build_deep_shared_missing_mock_project_files, + build_diamond_chain_test_project_files, build_dynamic_pivot_test_project_files, build_empty_partial_fixture_project_files, + build_expected_column_subset_project_files, build_explicit_typed_null_fixture_project_files, build_incompatible_fixture_type_project_files, build_invalid_partial_fixture_project_files, @@ -63,6 +73,48 @@ run_sqb, ) +_DIAMOND_ONCE_RENDERED_FRAGMENTS: tuple[str, ...] = ( + "__ref__stg_orders AS (", + "__ref__large_orders AS (", + "__ref__small_orders AS (", +) +_DIAMOND_STDOUT_FRAGMENTS: tuple[str, ...] = ( + "PASS=1 FAIL=1 TOTAL=2", + "expect assertion no_negative_large_orders", + "differing column large_count: actual=0, expected=3", +) + + +_ORDER_ROWS_BY_COLUMN_SUBSET: dict[str, str] = { + "partial_match": ( + "SELECT 1 AS order_id, 'paid' AS status UNION ALL SELECT 2 AS order_id, 'open' AS status" + ), + "partial_wrong": ( + "SELECT 1 AS order_id, 'paid' AS status UNION ALL SELECT 2 AS order_id, 'closed' AS status" + ), + "reordered": ( + "SELECT 10 AS amount, 'paid' AS status, 100 AS customer_id, 1 AS order_id " + "UNION ALL SELECT 14 AS amount, 'open' AS status, 200 AS customer_id, 2 AS order_id" + ), + "full_row": ( + "SELECT 1 AS order_id, 100 AS customer_id, 'paid' AS status, 10 AS amount " + "UNION ALL SELECT 2 AS order_id, 200 AS customer_id, 'open' AS status, 14 AS amount" + ), +} +_UNKNOWN_EXPECTED_COLUMN: dict[str, str] = { + "unknown_column": ( + "SELECT 1 AS order_id, 'north' AS region UNION ALL SELECT 2 AS order_id, 'south' AS region" + ), +} +_SUBSET_OUTCOME_FRAGMENTS: tuple[str, ...] = ( + "PASS=3 FAIL=1 TOTAL=4", + "unexpected sample 1: order_id=2, status=open; missing sample 1: order_id=2, status=closed", + "differing column status: actual=open, expected=closed", +) +_UNKNOWN_COLUMN_MESSAGE: str = ( + "expected output 'orders' lists columns that model 'orders' does not output: region" +) + @pytest.mark.parametrize( "test_case", @@ -72,7 +124,7 @@ repo_files=build_partial_source_fixture_project_files(), expected_stdout_fragment="PASS=1", expected_artifact_fragments=( - 'CAST(NULL AS TEXT) AS "status"', + 'CAST(NULL AS VARCHAR) AS "status"', "__sqlbuild_partial_fixture", ), ), @@ -81,7 +133,7 @@ repo_files=build_cte_partial_source_fixture_project_files(), expected_stdout_fragment="PASS=1", expected_artifact_fragments=( - 'CAST(NULL AS TEXT) AS "status"', + 'CAST(NULL AS VARCHAR) AS "status"', "__sqlbuild_partial_fixture", ), ), @@ -90,7 +142,7 @@ repo_files=build_clause_partial_source_fixture_project_files(), expected_stdout_fragment="PASS=1", expected_artifact_fragments=( - 'CAST(NULL AS TEXT) AS "status"', + 'CAST(NULL AS VARCHAR) AS "status"', "__sqlbuild_partial_fixture", ), ), @@ -99,7 +151,7 @@ repo_files=build_unspecified_nullability_fixture_project_files(), expected_stdout_fragment="PASS=1", expected_artifact_fragments=( - 'CAST(NULL AS TEXT) AS "status"', + 'CAST(NULL AS VARCHAR) AS "status"', "__sqlbuild_partial_fixture", ), ), @@ -117,7 +169,7 @@ repo_files=build_partial_seed_fixture_project_files(), expected_stdout_fragment="PASS=1", expected_artifact_fragments=( - 'CAST(NULL AS TEXT) AS "country_name"', + 'CAST(NULL AS VARCHAR) AS "country_name"', "__sqlbuild_partial_fixture", ), artifact_filename="test_countries.sql", @@ -126,7 +178,7 @@ description="nullable column outside the closure leaves fixture unchanged", repo_files=build_irrelevant_omitted_column_project_files(), expected_stdout_fragment="PASS=1", - expected_artifact_fragments=("__source__raw_orders AS (SELECT\n 1 AS order_id",), + expected_artifact_fragments=("__source__raw_orders AS (SELECT 1 AS order_id)",), unexpected_artifact_fragments=("__sqlbuild_partial_fixture",), ), PartialFixtureE2ETestCase( @@ -134,7 +186,7 @@ repo_files=build_star_partial_fixture_project_files(), expected_stdout_fragment="PASS=1", expected_artifact_fragments=( - 'CAST(NULL AS TEXT) AS "status"', + 'CAST(NULL AS VARCHAR) AS "status"', "__sqlbuild_partial_fixture", ), ), @@ -144,7 +196,7 @@ expected_stdout_fragment="PASS=1", expected_artifact_fragments=( "FALSE", - 'CAST(NULL AS TEXT) AS "status"', + 'CAST(NULL AS VARCHAR) AS "status"', "__sqlbuild_partial_fixture", ), ), @@ -170,8 +222,8 @@ repo_files=build_contract_empty_fixture_project_files(), expected_stdout_fragment="PASS=1", expected_artifact_fragments=( - 'CAST(NULL AS INT) AS "order_id"', - 'CAST(NULL AS TEXT) AS "status"', + 'CAST(NULL AS INTEGER) AS "order_id"', + 'CAST(NULL AS VARCHAR) AS "status"', "FALSE", ), unexpected_artifact_fragments=("__empty_fixture",), @@ -189,7 +241,7 @@ expected_stdout_fragment="PASS=1", expected_artifact_fragments=( '"__sqlbuild_partial_fixture".*', - 'CAST(NULL AS TEXT) AS "note"', + 'CAST(NULL AS VARCHAR) AS "note"', ), unexpected_artifact_fragments=('"__sqlbuild_partial_fixture"."Status"',), ), @@ -469,16 +521,6 @@ def test_given_invalid_fixture_when_testing_then_static_diagnostics_prevent_conn "Test plan inspection complete: 1 selected, 0 errors.", ), ), - SqlTestPlanInspectionE2ETestCase( - description="missing leaf mock is reported before execution", - repo_files=build_unsatisfied_leaf_test_project_files(), - expected_exit_code=1, - expected_stdout_fragments=( - "model 'stg_orders' references __source('raw') which has no mock", - "model 'fact_orders' references __source('raw') which has no mock", - "Test plan inspection failed: 1 selected, 2 errors.", - ), - ), SqlTestPlanInspectionE2ETestCase( description="ref mock shows replacement boundary", repo_files=build_mock_boundary_test_project_files(), @@ -527,6 +569,8 @@ def test_given_sql_test_when_inspecting_then_fixture_boundaries_and_chain_are_sh assert result.returncode == test_case.expected_exit_code, result.stdout + result.stderr for fragment in test_case.expected_stdout_fragments: assert fragment in result.stdout, result.stdout + result.stderr + for fragment in test_case.unexpected_stdout_fragments: + assert fragment not in result.stdout, result.stdout + result.stderr assert "Connecting to" not in result.stdout @@ -660,20 +704,18 @@ def test_given_waffle_shop_project_when_running_test_then_all_tests_pass( ), ), SqlAnalysisChainSqlTestE2ETestCase( - description="sql_analysis disabled chain test runs and keeps nested fallback sql", + description="sql_analysis disabled chain test renders each upstream model once", sql_analysis_enabled=False, expected_artifact_fragments=( "__actual__fact_orders AS (", - "FROM (", + "__ref__stg_orders AS (", + "FROM __ref__stg_orders", "'US' AS country", "' + x + ' AS literal_text", "'active' AS status", "'fact_orders' AS model_name", ), - unexpected_artifact_fragments=( - "__ref__stg_orders AS (", - "__actual_0", - ), + unexpected_artifact_fragments=("__actual_0",), ), ], ids=lambda case: case.description, @@ -943,3 +985,242 @@ def test_given_unknown_table_function_fixture_when_testing_model_then_compile_fa assert result.returncode == 1, result.stdout + result.stderr assert test_case.expected_stderr_fragment in result.stderr assert "Connecting to" not in result.stdout + + +@pytest.mark.parametrize( + "test_case", + ( + SharedGraphChainE2ETestCase( + description="shared graph chain with sql analysis", + sql_analysis_enabled=True, + expected_stdout_fragments=_DIAMOND_STDOUT_FRAGMENTS, + once_rendered_fragments=_DIAMOND_ONCE_RENDERED_FRAGMENTS, + ), + SharedGraphChainE2ETestCase( + description="shared graph chain without sql analysis", + sql_analysis_enabled=False, + expected_stdout_fragments=_DIAMOND_STDOUT_FRAGMENTS, + once_rendered_fragments=_DIAMOND_ONCE_RENDERED_FRAGMENTS, + ), + ), + ids=lambda case: case.description, +) +def test_given_shared_upstream_graph_when_running_test_then_outcomes_and_sql_match_compile( + test_case: SharedGraphChainE2ETestCase, + tmp_path: Path, +) -> None: + project_dir: Path = prepare_inline_project( + tmp_path=tmp_path, + project_name="diamond_chain_project", + repo_files=build_diamond_chain_test_project_files( + sql_analysis_enabled=test_case.sql_analysis_enabled + ), + ) + + test_result: subprocess.CompletedProcess[str] = run_sqb( + command=("--no-color", "test"), project_dir=project_dir + ) + compile_result: subprocess.CompletedProcess[str] = run_sqb( + command=("--no-color", "compile"), project_dir=project_dir + ) + + assert test_result.returncode == 1, test_result.stdout + test_result.stderr + assert compile_result.returncode == 0, compile_result.stdout + compile_result.stderr + for fragment in test_case.expected_stdout_fragments: + assert fragment in test_result.stdout, test_result.stdout + runtime_sql: str = next( + (project_dir / "target" / "run" / "tests").rglob("customer_order_mix_matches.sql") + ).read_text(encoding="utf-8") + compiled_sql: str = next( + (project_dir / "target" / "compiled" / "tests").rglob("customer_order_mix_matches.sql") + ).read_text(encoding="utf-8") + assert runtime_sql == compiled_sql + for fragment in test_case.once_rendered_fragments: + assert runtime_sql.count(fragment) == 1, fragment + + +@pytest.mark.parametrize( + "test_case", + ( + SharedGraphMissingMockE2ETestCase( + description="deep shared graph reports its missing source mock once", + layers=12, + expected_error=( + "test 'deep_orders_missing_mock': model 'orders_00_left' references " + "__source('raw_orders') which has no mock" + ), + expected_stdout_fragments=(), + ), + ), + ids=lambda case: case.description, +) +def test_given_deep_shared_graph_missing_mock_when_testing_then_error_is_reported_once( + test_case: SharedGraphMissingMockE2ETestCase, + tmp_path: Path, +) -> None: + project_dir: Path = prepare_inline_project( + tmp_path=tmp_path, + project_name="deep_missing_mock_project", + repo_files=build_deep_shared_missing_mock_project_files(layers=test_case.layers), + ) + + inspect_result: subprocess.CompletedProcess[str] = run_sqb( + command=("--no-color", "test", "--inspect"), project_dir=project_dir + ) + test_result: subprocess.CompletedProcess[str] = run_sqb( + command=("--no-color", "test"), project_dir=project_dir + ) + + for result in (inspect_result, test_result): + output: str = result.stdout + result.stderr + assert result.returncode == 1, output + assert output.count("which has no mock") == 1, output + assert test_case.expected_error in result.stderr, output + assert "Connecting to" not in result.stdout, output + for fragment in test_case.expected_stdout_fragments: + assert fragment in result.stdout, output + + +@pytest.mark.parametrize( + "test_case", + ( + ExpectedColumnSubsetE2ETestCase( + description="listed columns are compared by name with sql analysis", + sql_analysis_enabled=True, + command=("--no-color", "test"), + expected_tests=_ORDER_ROWS_BY_COLUMN_SUBSET, + expected_exit_code=1, + expected_output_fragments=_SUBSET_OUTCOME_FRAGMENTS, + unexpected_output_fragments=("customer_id=", "amount="), + ), + ExpectedColumnSubsetE2ETestCase( + description="listed columns are compared by name without sql analysis", + sql_analysis_enabled=False, + command=("--no-color", "test"), + expected_tests=_ORDER_ROWS_BY_COLUMN_SUBSET, + expected_exit_code=1, + expected_output_fragments=_SUBSET_OUTCOME_FRAGMENTS, + unexpected_output_fragments=("customer_id=", "amount="), + ), + ExpectedColumnSubsetE2ETestCase( + description="unknown expected column is a static error with sql analysis", + sql_analysis_enabled=True, + command=("--no-color", "test"), + expected_tests=_UNKNOWN_EXPECTED_COLUMN, + expected_exit_code=1, + expected_output_fragments=( + "SQL test 'unknown_column': tests/unit/unknown_column.sql:8:", + _UNKNOWN_COLUMN_MESSAGE, + ), + unexpected_output_fragments=("Connecting to", "Binder Error"), + ), + ExpectedColumnSubsetE2ETestCase( + description="unknown expected column is explained at run time without sql analysis", + sql_analysis_enabled=True, + command=("--no-color", "test", "--no-sql-analysis"), + expected_tests=_UNKNOWN_EXPECTED_COLUMN, + expected_exit_code=1, + expected_output_fragments=("[T005]", _UNKNOWN_COLUMN_MESSAGE), + unexpected_output_fragments=("Binder Error",), + ), + ), + ids=lambda case: case.description, +) +def test_given_expected_cte_column_subset_when_testing_then_only_listed_columns_are_compared( + test_case: ExpectedColumnSubsetE2ETestCase, + tmp_path: Path, +) -> None: + project_dir: Path = prepare_inline_project( + tmp_path=tmp_path, + project_name="expected_column_subset_project", + repo_files=build_expected_column_subset_project_files( + expected_tests=test_case.expected_tests, + sql_analysis_enabled=test_case.sql_analysis_enabled, + ), + ) + + result: subprocess.CompletedProcess[str] = run_sqb( + command=test_case.command, project_dir=project_dir + ) + + output: str = result.stdout + result.stderr + assert result.returncode == test_case.expected_exit_code, output + for fragment in test_case.expected_output_fragments: + assert fragment in output, output + for fragment in test_case.unexpected_output_fragments: + assert fragment not in output, output + + +@pytest.mark.parametrize( + "test_case", + ( + MissingMockCliE2ETestCase( + description="missing source mock fails test and compile until mocked", + expected_error=( + "test 'test_chain': model 'stg_orders' references __source('raw') which has no mock" + ), + compile_error_line=( + "error[S000]: test 'test_chain': model 'stg_orders' references __source('raw') " + "which has no mock" + ), + compile_summary_fragment="1 error, 0 warnings", + ), + ), + ids=lambda case: case.description, +) +def test_given_missing_mock_when_testing_and_compiling_then_error_is_reported_until_mocked( + test_case: MissingMockCliE2ETestCase, + tmp_path: Path, +) -> None: + project_dir: Path = prepare_inline_project( + tmp_path=tmp_path, + project_name="missing_mock_cli_project", + repo_files=build_unsatisfied_leaf_test_project_files(), + ) + + test_results: tuple[subprocess.CompletedProcess[str], ...] = tuple( + run_sqb(command=command, project_dir=project_dir) + for command in (("--no-color", "test"), ("--no-color", "test", "--inspect")) + ) + compile_results: tuple[subprocess.CompletedProcess[str], ...] = tuple( + run_sqb(command=("--no-color", "compile"), project_dir=project_dir) for _ in range(2) + ) + json_result: subprocess.CompletedProcess[str] = run_sqb( + command=("--no-color", "compile", "--json"), project_dir=project_dir + ) + (project_dir / "tests" / "unit" / "test_chain.sql").write_text( + MOCKED_UNSATISFIED_LEAF_TEST_SQL, encoding="utf-8" + ) + fixed_compile: subprocess.CompletedProcess[str] = run_sqb( + command=("--no-color", "compile"), project_dir=project_dir + ) + fixed_test: subprocess.CompletedProcess[str] = run_sqb( + command=("--no-color", "test"), project_dir=project_dir + ) + + for test_result in test_results: + test_output: str = test_result.stdout + test_result.stderr + assert test_result.returncode == 1, test_output + assert test_output.count(test_case.expected_error) == 1, test_output + assert "Connecting to" not in test_result.stdout, test_output + for compile_result in compile_results: + compile_output: str = compile_result.stdout + compile_result.stderr + assert compile_result.returncode == 1, compile_output + assert compile_result.stdout.count(test_case.compile_error_line) == 1, compile_output + summary_line: str = compile_result.stdout.strip().splitlines()[-2] + assert test_case.compile_summary_fragment in summary_line, compile_output + assert json_result.returncode == 1, json_result.stdout + json_result.stderr + payload: dict[str, Any] = json.loads(json_result.stdout) + assert payload["has_errors"] is True + assert payload["summary"]["errors"] == 1 + assert [ + (diagnostic["phase"], diagnostic["code"], diagnostic["message"]) + for diagnostic in payload["diagnostics"] + ] == [("test", "S000", test_case.expected_error)] + fixed_compile_output: str = fixed_compile.stdout + fixed_compile.stderr + assert fixed_compile.returncode == 0, fixed_compile_output + assert "which has no mock" not in fixed_compile_output + assert "0 errors" in fixed_compile.stdout, fixed_compile_output + fixed_test_output: str = fixed_test.stdout + fixed_test.stderr + assert fixed_test.returncode == 0, fixed_test_output + assert "PASS=1 FAIL=0 TOTAL=1" in fixed_test.stdout, fixed_test_output diff --git a/tests/e2e/src/sqlbuild/cli/commands/main/test/test_upstream_function_fallback.py b/tests/e2e/src/sqlbuild/cli/commands/main/test/test_upstream_function_fallback.py new file mode 100644 index 0000000000..7084916745 --- /dev/null +++ b/tests/e2e/src/sqlbuild/cli/commands/main/test/test_upstream_function_fallback.py @@ -0,0 +1,81 @@ +"""Exercise SQL-test chains that contain a function call requiring textual resolution.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest + +from tests.e2e.src.sqlbuild.cli.commands.main.test._test_types import SqlTestE2ETestCase +from tests.e2e.src.sqlbuild.cli.commands.shared.helpers import ( + execute_duckdb, + prepare_inline_project, + run_sqb, +) + + +@pytest.mark.parametrize( + "test_case", + [ + SqlTestE2ETestCase( + description="fully mocked upstream function chain executes after textual fallback", + expected_exit_code=0, + expected_stdout_fragment="PASS=1", + ) + ], + ids=lambda case: case.description, +) +def test_given_upstream_function_when_compiling_and_testing_then_mocked_chain_executes( + test_case: SqlTestE2ETestCase, + tmp_path: Path, +) -> None: + project_dir: Path = prepare_inline_project( + tmp_path=tmp_path, + project_name="orders_function_chain", + repo_files={ + "sqlbuild_project.toml": ( + 'name = "orders_function_chain"\nadapter = "duckdb"\n' + '[connection]\ndatabase = "orders.duckdb"\n' + ), + "sources/raw.yml": ( + "sources:\n - name: raw_orders\n schema: main\n table: raw_orders\n" + ), + "functions/sql/identity_value.sql": ( + "FUNCTION (arguments (value INTEGER), returns INTEGER);\nvalue\n" + ), + "models/stg_orders.sql": ( + "MODEL (materialized table);\n" + 'SELECT __udf("identity_value")(order_id) AS order_id ' + 'FROM __source("raw_orders")\n' + ), + "models/orders.sql": ( + 'MODEL (materialized table);\nSELECT * FROM __ref("stg_orders")\n' + ), + "tests/unit/orders_case.sql": ( + 'TEST (name "orders_case");\nWITH\n' + "__source__raw_orders AS (SELECT 1 AS order_id),\n" + "__expected__orders AS (SELECT 1 AS order_id),\n" + "__assert__positive AS (" + 'SELECT * FROM __ref("orders") WHERE order_id < 0)\nSELECT 1\n' + ), + }, + ) + + compiled: subprocess.CompletedProcess[str] = run_sqb( + command=("compile",), project_dir=project_dir + ) + execute_duckdb( + db_path=project_dir / "orders.duckdb", + sql="CREATE TABLE raw_orders AS SELECT 1 AS order_id", + ) + built: subprocess.CompletedProcess[str] = run_sqb( + command=("build", "--no-tests", "--no-audits"), project_dir=project_dir + ) + tested: subprocess.CompletedProcess[str] = run_sqb(command=("test",), project_dir=project_dir) + + assert compiled.returncode == 0, compiled.stdout + compiled.stderr + assert built.returncode == 0, built.stdout + built.stderr + assert tested.returncode == test_case.expected_exit_code, tested.stdout + tested.stderr + assert test_case.expected_stdout_fragment in tested.stdout + assert "has no mock" not in compiled.stdout + compiled.stderr + tested.stdout + tested.stderr diff --git a/tests/integration/src/sqlbuild/cli/commands/main/_test_types.py b/tests/integration/src/sqlbuild/cli/commands/main/_test_types.py index ad41066a02..33339a443e 100644 --- a/tests/integration/src/sqlbuild/cli/commands/main/_test_types.py +++ b/tests/integration/src/sqlbuild/cli/commands/main/_test_types.py @@ -601,3 +601,24 @@ class BuildTestPlanningTestCase: build_flags: tuple[str, ...] expected_exit_code: int expected_fragment: str + + +@dataclass(frozen=True) +class FormatPathArgumentsIntegrationTestCase: + """One `sqb format` invocation that names files directly.""" + + description: str + arguments: tuple[str, ...] + expected_exit_code: int + expected_formatted: tuple[str, ...] + expected_unchanged: tuple[str, ...] + expected_output_fragment: str + + +@dataclass(frozen=True) +class LeadingCteCommentFormatIntegrationTestCase: + """One leading CTE comment that must survive formatting and compile.""" + + description: str + authored_sql: str + expected_fragment: str diff --git a/tests/integration/src/sqlbuild/cli/commands/main/test_format.py b/tests/integration/src/sqlbuild/cli/commands/main/test_format.py index a257ae8ca3..bad46249d0 100644 --- a/tests/integration/src/sqlbuild/cli/commands/main/test_format.py +++ b/tests/integration/src/sqlbuild/cli/commands/main/test_format.py @@ -13,11 +13,13 @@ CanonicalFixtureFormatIntegrationTestCase, DescriptionFormatIntegrationTestCase, FormatCompileIntegrationTestCase, + FormatPathArgumentsIntegrationTestCase, FormatSafetyIntegrationTestCase, FormatScopeIntegrationTestCase, FormatterDeclineIntegrationTestCase, FormatWarningIntegrationTestCase, FromValuesFormatIntegrationTestCase, + LeadingCteCommentFormatIntegrationTestCase, MixedFromValuesFormatIntegrationTestCase, TypedNullFormatIntegrationTestCase, ) @@ -26,6 +28,8 @@ write_snowflake_format_test, ) +_UNFORMATTED_ORDERS_SQL: str = "MODEL (materialized table);\nselect 1 as order_id\n" + @pytest.mark.parametrize( "test_case", @@ -793,3 +797,132 @@ def test_given_missing_description_when_formatting_then_warning_does_not_fail( assert isinstance(violations, list) assert violations[0]["code"] == test_case.expected_code assert violations[0]["severity"] == test_case.expected_severity + + +@pytest.mark.parametrize( + "test_case", + [ + FormatPathArgumentsIntegrationTestCase( + description="positional file formats only that file", + arguments=("models/marts/orders.sql",), + expected_exit_code=0, + expected_formatted=("models/marts/orders.sql",), + expected_unchanged=("models/staging/stg_orders.sql",), + expected_output_fragment="1 files", + ), + FormatPathArgumentsIntegrationTestCase( + description="bare file path in select formats only that file", + arguments=("--select", "models/staging/stg_orders.sql"), + expected_exit_code=0, + expected_formatted=("models/staging/stg_orders.sql",), + expected_unchanged=("models/marts/orders.sql",), + expected_output_fragment="1 files", + ), + FormatPathArgumentsIntegrationTestCase( + description="positional folder formats every file below it", + arguments=("models",), + expected_exit_code=0, + expected_formatted=("models/marts/orders.sql", "models/staging/stg_orders.sql"), + expected_unchanged=(), + expected_output_fragment="2 files", + ), + FormatPathArgumentsIntegrationTestCase( + description="missing positional path is a clear error", + arguments=("models/marts/missing.sql",), + expected_exit_code=1, + expected_formatted=(), + expected_unchanged=("models/marts/orders.sql", "models/staging/stg_orders.sql"), + expected_output_fragment="format path 'models/marts/missing.sql' does not exist", + ), + FormatPathArgumentsIntegrationTestCase( + description="path outside formatted folders lists the valid roots", + arguments=("sqlbuild_project.toml",), + expected_exit_code=1, + expected_formatted=(), + expected_unchanged=("models/marts/orders.sql", "models/staging/stg_orders.sql"), + expected_output_fragment="use a path under models/, tests/", + ), + FormatPathArgumentsIntegrationTestCase( + description="unknown path selector root lists the formatter roots", + arguments=("--select", "path:macros"), + expected_exit_code=1, + expected_formatted=(), + expected_unchanged=("models/marts/orders.sql", "models/staging/stg_orders.sql"), + expected_output_fragment="'functions/'", + ), + ], + ids=lambda case: case.description, +) +def test_given_file_paths_when_formatting_then_only_named_files_change( + test_case: FormatPathArgumentsIntegrationTestCase, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + (tmp_path / "sqlbuild_project.toml").write_text( + 'name = "orders"\nadapter = "duckdb"\n', encoding="utf-8" + ) + for relative_path in ("models/marts/orders.sql", "models/staging/stg_orders.sql"): + (tmp_path / relative_path).parent.mkdir(parents=True, exist_ok=True) + (tmp_path / relative_path).write_text(_UNFORMATTED_ORDERS_SQL, encoding="utf-8") + monkeypatch.chdir(tmp_path) + + exit_code: int = main(["--no-color", "format", *test_case.arguments]) + captured: CaptureResult[str] = capsys.readouterr() + + assert exit_code == test_case.expected_exit_code + assert test_case.expected_output_fragment in captured.out + captured.err + assert { + path: (tmp_path / path).read_text(encoding="utf-8") != _UNFORMATTED_ORDERS_SQL + for path in (*test_case.expected_formatted, *test_case.expected_unchanged) + } == { + **dict.fromkeys(test_case.expected_formatted, True), + **dict.fromkeys(test_case.expected_unchanged, False), + } + + +@pytest.mark.parametrize( + "test_case", + [ + LeadingCteCommentFormatIntegrationTestCase( + description="comment block before a middle CTE stays above it and compiles", + authored_sql=( + "MODEL (materialized view);\n\n" + 'WITH\nupstream AS (\n SELECT *\n FROM __ref("raw_orders")\n),\n\n' + "-- Explains the next CTE: line one,\n-- line two.\n" + "distinct_rows AS (\n SELECT order_id, customer_id\n FROM upstream\n" + " GROUP BY ALL\n)\n\nSELECT order_id, customer_id FROM distinct_rows\n" + ), + expected_fragment="),\n-- Explains the next CTE: line one,\n-- line two.\ndistinct_rows AS (", + ), + ], + ids=lambda case: case.description, +) +def test_given_leading_cte_comment_when_formatting_then_comment_rule_still_passes( + test_case: LeadingCteCommentFormatIntegrationTestCase, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + (tmp_path / "sqlbuild_project.toml").write_text( + 'name = "orders"\nadapter = "duckdb"\n\n[rules]\nselect = ["SQBRSQL033"]\n', + encoding="utf-8", + ) + models: Path = tmp_path / "models" + models.mkdir() + (models / "raw_orders.sql").write_text( + "MODEL (materialized view);\n\nSELECT 1 AS order_id, 2 AS customer_id\n", + encoding="utf-8", + ) + model_path: Path = models / "distinct_orders.sql" + model_path.write_text(test_case.authored_sql, encoding="utf-8") + + format_exit: int = main(["--project-dir", str(tmp_path), "--no-color", "format"]) + formatted_sql: str = model_path.read_text(encoding="utf-8") + second_format_exit: int = main(["--project-dir", str(tmp_path), "--no-color", "format"]) + compile_exit: int = main(["--project-dir", str(tmp_path), "--no-color", "compile"]) + output: CaptureResult[str] = capsys.readouterr() + + assert (format_exit, second_format_exit, compile_exit) == (0, 0, 0), output.out + output.err + assert test_case.expected_fragment in formatted_sql + assert model_path.read_text(encoding="utf-8") == formatted_sql + assert "SQBRSQL033" not in output.out + output.err diff --git a/tests/integration/src/sqlbuild/compiler/planner/_helpers/sql_test_assembly/helpers.py b/tests/integration/src/sqlbuild/compiler/planner/_helpers/sql_test_assembly/helpers.py index be87de554b..a99efc65fe 100644 --- a/tests/integration/src/sqlbuild/compiler/planner/_helpers/sql_test_assembly/helpers.py +++ b/tests/integration/src/sqlbuild/compiler/planner/_helpers/sql_test_assembly/helpers.py @@ -94,6 +94,10 @@ def build_test_and_project( mock_model_names=tuple(test_case.mock_ref_ctes.keys()), mock_source_names=tuple(test_case.mock_source_ctes.keys()), expected_model_names=test_case.expected_model_names, + expected_ctes=tuple( + CompileSqlTestCte(name=f"__expected__{name}", sql_body=body) + for name, body in test_case.expected_cte_bodies.items() + ), ), ) diff --git a/tests/integration/src/sqlbuild/compiler/planner/_helpers/sql_test_assembly/test_execute_chain.py b/tests/integration/src/sqlbuild/compiler/planner/_helpers/sql_test_assembly/test_execute_chain.py index 06e7b02286..8d0cee809f 100644 --- a/tests/integration/src/sqlbuild/compiler/planner/_helpers/sql_test_assembly/test_execute_chain.py +++ b/tests/integration/src/sqlbuild/compiler/planner/_helpers/sql_test_assembly/test_execute_chain.py @@ -18,7 +18,6 @@ from sqlbuild.compiler.discovery.main.discover import discover_project_inputs from sqlbuild.compiler.discovery.models import DiscoveredSqlTestBlock, DiscoveredSqlTestFile from sqlbuild.compiler.pipeline.main.compiled_project import build_compiled_project -from sqlbuild.compiler.planner._helpers.sql_tests.assembly import plan_test from sqlbuild.compiler.planner.models import ChainStep, SqlTestPlanEntry from sqlbuild.executor.testing.main._execute import execute_sql_test from sqlbuild.executor.testing.models import SqlTestExecutionResult @@ -31,6 +30,9 @@ from tests.integration.src.sqlbuild.compiler.planner._helpers.sql_test_assembly.helpers import ( build_test_and_project, ) +from tests.unit.src.sqlbuild.compiler.planner._helpers.sql_test_assembly.helpers import ( + plan_single_test, +) @pytest.mark.parametrize( @@ -79,7 +81,7 @@ def test_given_parameterized_model_test_when_compiled_and_executed_then_duckdb_p discovered_inputs=discover_project_inputs(project_dir=tmp_path), adapter=adapter, ) - entry, warnings = plan_test( + entry, warnings = plan_single_test( test=project.sql_tests[0], adapter=adapter, project=project, @@ -187,8 +189,6 @@ def test_given_parameterized_model_test_when_compiled_and_executed_then_duckdb_p }, expected_chain_length=3, expected_results={ - "A": ((1, 50),), - "B": ((1, 100),), "C": ((1, 101),), }, ), @@ -312,19 +312,18 @@ def test_given_chain_when_executing_resolved_sql_then_produces_expected_rows( compiled_test, project = build_test_and_project(test_case) entry: SqlTestPlanEntry - entry, _ = plan_test(test=compiled_test, project=project, adapter=DuckDbAdapter()) + entry, _ = plan_single_test(test=compiled_test, project=project, adapter=DuckDbAdapter()) assert len(entry.chain) == test_case.expected_chain_length - step: ChainStep - for step in entry.chain: - expected_rows: tuple[tuple[object, ...], ...] | None = test_case.expected_results.get( - step.model_name - ) - result: Any = connection.execute(step.resolved_sql) + steps_by_name: dict[str, ChainStep] = {step.model_name: step for step in entry.chain} + model_name: str + expected_rows: tuple[tuple[object, ...], ...] + for model_name, expected_rows in test_case.expected_results.items(): + result: Any = connection.execute(steps_by_name[model_name].resolved_sql) rows: list[Any] = result.fetchall() actual: tuple[tuple[object, ...], ...] = tuple(tuple(row) for row in rows) - assert expected_rows is None or actual == expected_rows + assert actual == expected_rows @pytest.mark.parametrize( @@ -387,7 +386,7 @@ def test_given_macro_test_plan_when_executing_then_it_passes_direct_comparison( ), ) - entry, warnings = plan_test( + entry, warnings = plan_single_test( test=sql_test, adapter=DuckDbAdapter(), project=CompiledProject( @@ -481,7 +480,7 @@ def test_given_sql_analysis_enabled_when_planning_chain_then_step_sql_remains_ex compiled_test, project = build_test_and_project(test_case) entry: SqlTestPlanEntry - entry, _ = plan_test( + entry, _ = plan_single_test( test=compiled_test, project=project, adapter=DuckDbAdapter(), diff --git a/tests/unit/src/sqlbuild/cli/commands/_helpers/lineage/_test_types.py b/tests/unit/src/sqlbuild/cli/commands/_helpers/lineage/_test_types.py index 6dc212aeb1..00b008da24 100644 --- a/tests/unit/src/sqlbuild/cli/commands/_helpers/lineage/_test_types.py +++ b/tests/unit/src/sqlbuild/cli/commands/_helpers/lineage/_test_types.py @@ -61,7 +61,7 @@ class LargeColumnLineageOutputTestCase: @dataclass(frozen=True) class LineageCompiledGraphRequirementTestCase: description: str - target: str | None + targets: tuple[str, ...] select: tuple[str, ...] include_uses: bool expected_compiled_graph_required: bool diff --git a/tests/unit/src/sqlbuild/cli/commands/_helpers/lineage/test_execution.py b/tests/unit/src/sqlbuild/cli/commands/_helpers/lineage/test_execution.py index 69afdf3a68..26342496bd 100644 --- a/tests/unit/src/sqlbuild/cli/commands/_helpers/lineage/test_execution.py +++ b/tests/unit/src/sqlbuild/cli/commands/_helpers/lineage/test_execution.py @@ -14,28 +14,28 @@ ( LineageCompiledGraphRequirementTestCase( description="relation target uses structural lineage", - target="fact_orders", + targets=("fact_orders",), select=(), include_uses=False, expected_compiled_graph_required=False, ), LineageCompiledGraphRequirementTestCase( description="selector uses structural lineage", - target=None, + targets=(), select=("fact_orders+",), include_uses=False, expected_compiled_graph_required=False, ), LineageCompiledGraphRequirementTestCase( description="column target retains column analysis", - target="fact_orders.order_id", + targets=("fact_orders.order_id",), select=(), include_uses=False, expected_compiled_graph_required=True, ), LineageCompiledGraphRequirementTestCase( description="semantic uses retain the compiled graph", - target="fact_orders", + targets=("fact_orders",), select=(), include_uses=True, expected_compiled_graph_required=True, @@ -48,7 +48,7 @@ def test_given_lineage_request_when_checking_analysis_requirement_then_matches_t ) -> None: request: LineageCommandRequest = LineageCommandRequest( project_dir=None, - target=test_case.target, + targets=test_case.targets, select=test_case.select, include_uses=test_case.include_uses, ) diff --git a/tests/unit/src/sqlbuild/cli/commands/_helpers/lineage/test_output.py b/tests/unit/src/sqlbuild/cli/commands/_helpers/lineage/test_output.py index e170c369d6..b983cc5fca 100644 --- a/tests/unit/src/sqlbuild/cli/commands/_helpers/lineage/test_output.py +++ b/tests/unit/src/sqlbuild/cli/commands/_helpers/lineage/test_output.py @@ -63,7 +63,7 @@ def test_given_lineage_graph_when_formatting_then_returns_expected_human_output( graph: ProjectGraph = build_lineage_test_graph() lineage_graph: LineageGraph = select_target_lineage( graph=graph, - target="fact_orders", + targets=("fact_orders",), direction="upstream", depth=None, ) @@ -174,7 +174,7 @@ def test_given_lineage_graph_when_formatting_with_color_then_styles_semantic_par graph: ProjectGraph = build_lineage_test_graph() lineage_graph: LineageGraph = select_target_lineage( graph=graph, - target="fact_orders", + targets=("fact_orders",), direction="upstream", depth=None, ) diff --git a/tests/unit/src/sqlbuild/cli/commands/_helpers/lineage/test_selection.py b/tests/unit/src/sqlbuild/cli/commands/_helpers/lineage/test_selection.py index b660714d01..78c628baeb 100644 --- a/tests/unit/src/sqlbuild/cli/commands/_helpers/lineage/test_selection.py +++ b/tests/unit/src/sqlbuild/cli/commands/_helpers/lineage/test_selection.py @@ -69,7 +69,7 @@ def test_given_target_lineage_request_when_selecting_then_returns_expected_subgr result: LineageGraph = select_target_lineage( graph=graph, - target=test_case.target, + targets=(test_case.target,), direction=test_case.direction, depth=test_case.depth, ) diff --git a/tests/unit/src/sqlbuild/cli/commands/main/compile/_test_types.py b/tests/unit/src/sqlbuild/cli/commands/main/compile/_test_types.py index 833063594a..c827d6d2af 100644 --- a/tests/unit/src/sqlbuild/cli/commands/main/compile/_test_types.py +++ b/tests/unit/src/sqlbuild/cli/commands/main/compile/_test_types.py @@ -21,6 +21,13 @@ class TargetWriterCacheTestCase: expected_builder_calls: int +@dataclass(frozen=True) +class TargetWriterPlanningErrorTestCase: + description: str + expected_builder_calls: int + expected_message: str + + @dataclass(frozen=True) class CompileCommandTestCase: description: str diff --git a/tests/unit/src/sqlbuild/cli/commands/main/compile/helpers.py b/tests/unit/src/sqlbuild/cli/commands/main/compile/helpers.py index 79a0fdefb7..33ca4f65a2 100644 --- a/tests/unit/src/sqlbuild/cli/commands/main/compile/helpers.py +++ b/tests/unit/src/sqlbuild/cli/commands/main/compile/helpers.py @@ -355,6 +355,28 @@ def build_cached_target_writer_project(*, target_dir: Path) -> CompiledProject: ) +def build_missing_mock_target_writer_project(*, target_dir: Path) -> CompiledProject: + """Build a static cached project whose SQL test omits a required source mock.""" + + compiled_test, project = build_test_and_project( + PlanTestChainTestCase( + description="missing mock target writer project", + model_queries={"orders": 'SELECT order_id FROM __source("raw_orders")'}, + mock_ref_ctes={}, + mock_source_ctes={}, + helper_ctes={}, + expected_model_names=("orders",), + expected_chain_length=1, + expected_cte_bodies={"orders": "SELECT 2 AS order_id"}, + ) + ) + return replace( + project, + compile_cache_dir=target_dir / "cache" / "compiler", + sql_tests=(compiled_test,), + ) + + def build_compile_output_graph(*, model_names: tuple[str, ...]) -> ProjectGraph: """Build a static project graph for compile output formatter tests.""" diff --git a/tests/unit/src/sqlbuild/cli/commands/main/compile/test_compile_target_writer.py b/tests/unit/src/sqlbuild/cli/commands/main/compile/test_compile_target_writer.py index 9915a6b782..c5579797b0 100644 --- a/tests/unit/src/sqlbuild/cli/commands/main/compile/test_compile_target_writer.py +++ b/tests/unit/src/sqlbuild/cli/commands/main/compile/test_compile_target_writer.py @@ -18,15 +18,18 @@ WrittenTarget, ) from sqlbuild.compiler.compile.models import CompiledModel, CompiledProject, CompiledSqlTest +from sqlbuild.compiler.compile.types import CompiledResourceType, DiagnosticPhase from sqlbuild.compiler.planner.models import ChainStep, PlanOutput, SqlTestPlanEntry from sqlbuild.executor.testing.main.comparison_sql import build_sql_test_comparison_sql from tests.unit.src.sqlbuild.cli.commands.main.compile._test_types import ( ExpectedMessageTestCase, TargetWriterCacheTestCase, + TargetWriterPlanningErrorTestCase, TargetWriterTestCase, ) from tests.unit.src.sqlbuild.cli.commands.main.compile.helpers import ( build_cached_target_writer_project, + build_missing_mock_target_writer_project, build_static_target_writer_project, build_target_writer_plan_output, read_target_files, @@ -342,6 +345,49 @@ def test_given_unchanged_test_artifact_when_writing_again_then_skips_test_plan_r assert planner_spy.call_count == test_case.expected_builder_calls +@pytest.mark.parametrize( + "test_case", + ( + TargetWriterPlanningErrorTestCase( + description="missing mock is reported on every write", + expected_builder_calls=1, + expected_message=("model 'orders' references __source('raw_orders') which has no mock"), + ), + ), + ids=lambda case: case.description, +) +def test_given_test_with_planning_error_when_writing_twice_then_error_is_not_cached( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + test_case: TargetWriterPlanningErrorTestCase, +) -> None: + target_dir: Path = tmp_path / "target" + project: CompiledProject = build_missing_mock_target_writer_project(target_dir=target_dir) + first: WrittenTarget = write_static_compile_target( + target_dir=target_dir, + adapter=DuckDbAdapter(), + project=project, + ) + planner_spy: Mock = Mock(wraps=target_writer_module.plan_and_render_sql_test_artifacts) + monkeypatch.setattr(target_writer_module, "plan_and_render_sql_test_artifacts", planner_spy) + + second: WrittenTarget = write_static_compile_target( + target_dir=target_dir, + adapter=DuckDbAdapter(), + project=project, + ) + + assert planner_spy.call_count == test_case.expected_builder_calls + assert first.diagnostics == second.diagnostics + (diagnostic,) = second.diagnostics + assert diagnostic.is_error + assert diagnostic.phase is DiagnosticPhase.TEST + assert diagnostic.code == "S000" + assert diagnostic.resource_type is CompiledResourceType.SQL_TEST + assert diagnostic.resource_name == project.sql_tests[0].name + assert test_case.expected_message in diagnostic.message + + @pytest.mark.parametrize( "test_case", ( diff --git a/tests/unit/src/sqlbuild/cli/commands/main/entry/test_main.py b/tests/unit/src/sqlbuild/cli/commands/main/entry/test_main.py index 0af485e49f..339cf22e89 100644 --- a/tests/unit/src/sqlbuild/cli/commands/main/entry/test_main.py +++ b/tests/unit/src/sqlbuild/cli/commands/main/entry/test_main.py @@ -1542,7 +1542,7 @@ def run_lineage(request: LineageCommandRequest) -> int: assert received_args == [ LineageCommandRequest( project_dir=None, - target="fact_orders", + targets=("fact_orders",), output_format="json", direction="both", depth="2", diff --git a/tests/unit/src/sqlbuild/compiler/manifest/_test_types.py b/tests/unit/src/sqlbuild/compiler/manifest/_test_types.py index c0cdf95941..ce4a3c9bc1 100644 --- a/tests/unit/src/sqlbuild/compiler/manifest/_test_types.py +++ b/tests/unit/src/sqlbuild/compiler/manifest/_test_types.py @@ -122,6 +122,7 @@ class ManifestSqlTestNodeTestCase: expected_sqlbuild_test_type: str expected_compiled_code_fragment: str expected_depends_on_nodes: tuple[str, ...] + unexpected_compiled_code_fragments: tuple[str, ...] = () @dataclass(frozen=True) diff --git a/tests/unit/src/sqlbuild/compiler/manifest/test_main.py b/tests/unit/src/sqlbuild/compiler/manifest/test_main.py index 5639c29c60..73797d57f1 100644 --- a/tests/unit/src/sqlbuild/compiler/manifest/test_main.py +++ b/tests/unit/src/sqlbuild/compiler/manifest/test_main.py @@ -843,6 +843,7 @@ def test_given_audit_when_building_manifest_then_produces_test_node( expected_sqlbuild_test_type="sql_native", expected_compiled_code_fragment="-- step: orders", expected_depends_on_nodes=(f"model.{_PROJECT}.orders",), + unexpected_compiled_code_fragments=("-- step: stg_orders",), ), ], ids=lambda case: case.description, @@ -853,6 +854,7 @@ def test_given_sql_test_when_building_manifest_then_produces_test_node( model: CompiledModel = build_test_model(name="orders") project: CompiledProject = build_test_project(models=(model,)) chain: tuple[ChainStep, ...] = ( + ChainStep(model_name="stg_orders", resolved_sql=""), ChainStep( model_name="orders", resolved_sql="SELECT 1 AS id", @@ -893,6 +895,9 @@ def test_given_sql_test_when_building_manifest_then_produces_test_node( assert node["resource_type"] == test_case.expected_resource_type assert node["name"] == test_case.expected_name assert node["meta"]["sqlbuild_test_type"] == test_case.expected_sqlbuild_test_type + assert test_case.expected_compiled_code_fragment in node["compiled_code"] + for fragment in test_case.unexpected_compiled_code_fragments: + assert fragment not in node["compiled_code"] assert node["checksum"] == {"name": "sha256", "checksum": "f" * 64} assert node["meta"]["sqlbuild_case_index"] == 0 assert node["meta"]["sqlbuild_case_fingerprint"] == "f" * 64 diff --git a/tests/unit/src/sqlbuild/compiler/planner/_helpers/resolve/_test_types.py b/tests/unit/src/sqlbuild/compiler/planner/_helpers/resolve/_test_types.py index e33269b97f..b670e4d119 100644 --- a/tests/unit/src/sqlbuild/compiler/planner/_helpers/resolve/_test_types.py +++ b/tests/unit/src/sqlbuild/compiler/planner/_helpers/resolve/_test_types.py @@ -116,15 +116,6 @@ class RefResolutionTestCase: cursor_bounds: CursorBounds | None = None -@dataclass(frozen=True) -class TableFunctionFixtureResolutionTestCase: - description: str - query_sql: str - fixtures: dict[str, str] - expected_sql: str - expected_reached: frozenset[str] - - @dataclass(frozen=True) class ApplyDeferredTargetsTestCase: description: str diff --git a/tests/unit/src/sqlbuild/compiler/planner/_helpers/resolve/test_refs.py b/tests/unit/src/sqlbuild/compiler/planner/_helpers/resolve/test_refs.py index f210414fb5..76115fd470 100644 --- a/tests/unit/src/sqlbuild/compiler/planner/_helpers/resolve/test_refs.py +++ b/tests/unit/src/sqlbuild/compiler/planner/_helpers/resolve/test_refs.py @@ -15,7 +15,6 @@ apply_deferred_locations, resolve_dbt_ref_references, resolve_ref_references, - resolve_table_function_fixture_references, resolve_table_function_references, resolve_udf_references, ) @@ -28,7 +27,6 @@ from tests.unit.src.sqlbuild.compiler.planner._helpers.resolve._test_types import ( ApplyDeferredTargetsTestCase, RefResolutionTestCase, - TableFunctionFixtureResolutionTestCase, ) from tests.unit.src.sqlbuild.compiler.planner._helpers.resolve.helpers import ( BracketTableFunctionCallAdapter, @@ -338,64 +336,6 @@ def test_given_table_function_marker_when_resolving_then_returns_expected_sql( assert result == test_case.expected_sql -@pytest.mark.parametrize( - "test_case", - ( - TableFunctionFixtureResolutionTestCase( - description="replaces the complete nested argument invocation", - query_sql=( - 'SELECT * FROM __table_fn("customer_orders")(' - "COALESCE((SELECT MAX(customer_id) FROM customers), 42)) AS orders" - ), - fixtures={"customer_orders": "(SELECT 7 AS order_id)"}, - expected_sql="SELECT * FROM (SELECT 7 AS order_id) AS orders", - expected_reached=frozenset({"customer_orders"}), - ), - TableFunctionFixtureResolutionTestCase( - description="outer fixture consumes nested table function arguments", - query_sql=( - 'SELECT * FROM __table_fn("customer_orders")(' - '(SELECT MAX(id) FROM __table_fn("customer_profiles")(1))) AS orders' - ), - fixtures={ - "customer_orders": "(SELECT 7 AS order_id)", - "customer_profiles": "(SELECT 1 AS id)", - }, - expected_sql="SELECT * FROM (SELECT 7 AS order_id) AS orders", - expected_reached=frozenset({"customer_orders"}), - ), - TableFunctionFixtureResolutionTestCase( - description="ignores comments literals and unmocked functions", - query_sql=( - "SELECT '__table_fn(\"customer_orders\")(42)' AS example " - 'FROM __table_fn("other_orders")(42) ' - '-- __table_fn("customer_orders")(42)' - ), - fixtures={"customer_orders": "(SELECT 7 AS order_id)"}, - expected_sql=( - "SELECT '__table_fn(\"customer_orders\")(42)' AS example " - 'FROM __table_fn("other_orders")(42) ' - '-- __table_fn("customer_orders")(42)' - ), - expected_reached=frozenset(), - ), - ), - ids=lambda case: case.description, -) -def test_given_table_function_fixture_when_resolving_then_replaces_only_executable_call( - test_case: TableFunctionFixtureResolutionTestCase, -) -> None: - result: str - reached: frozenset[str] - result, reached = resolve_table_function_fixture_references( - query_sql=test_case.query_sql, - fixtures=test_case.fixtures, - ) - - assert result == test_case.expected_sql - assert reached == test_case.expected_reached - - @pytest.mark.parametrize( "test_case", [ diff --git a/tests/unit/src/sqlbuild/compiler/planner/_helpers/sql_test_assembly/_test_types.py b/tests/unit/src/sqlbuild/compiler/planner/_helpers/sql_test_assembly/_test_types.py index 5255bc000a..b0ad04bf83 100644 --- a/tests/unit/src/sqlbuild/compiler/planner/_helpers/sql_test_assembly/_test_types.py +++ b/tests/unit/src/sqlbuild/compiler/planner/_helpers/sql_test_assembly/_test_types.py @@ -48,7 +48,7 @@ class PlanMacroTestCase: class AssertionChainCteErrorTestCase: description: str assertion_sql: str - resolved_chain: dict[str, str] + model_queries: dict[str, str] expected_error_fragment: str diff --git a/tests/unit/src/sqlbuild/compiler/planner/_helpers/sql_test_assembly/helpers.py b/tests/unit/src/sqlbuild/compiler/planner/_helpers/sql_test_assembly/helpers.py index 1f1ac143b2..59871f47f3 100644 --- a/tests/unit/src/sqlbuild/compiler/planner/_helpers/sql_test_assembly/helpers.py +++ b/tests/unit/src/sqlbuild/compiler/planner/_helpers/sql_test_assembly/helpers.py @@ -8,6 +8,7 @@ from types import MappingProxyType from typing import Any +from sqlbuild.adapter.contract.classes.base_adapter import BaseAdapter from sqlbuild.adapters.duckdb.classes.duckdb_adapter import DuckDbAdapter from sqlbuild.compiler.compile._helpers.render.macros import expand_sql_macros from sqlbuild.compiler.compile.constants import ( @@ -37,11 +38,19 @@ DiscoveredSqlTestBlock, DiscoveredSqlTestFile, ) +from sqlbuild.compiler.planner._helpers.sql_tests.assembly import plan_sql_tests from sqlbuild.compiler.planner._helpers.sql_tests.comments import uncommented_pattern_matches from sqlbuild.compiler.planner._helpers.sql_tests.native_planning import ( plan_and_render_sql_test_artifacts, + plan_sql_tests_natively, + sql_test_plan_error_messages, +) +from sqlbuild.compiler.planner.models import ( + NativeSqlTestPlan, + PlanWarning, + SqlTestPlanEntry, + SqlTestPlanResult, ) -from sqlbuild.compiler.planner.models import PlanWarning, SqlTestPlanEntry from sqlbuild.compiler.sql_analysis.main.import_polyglot_sql import import_polyglot_sql from sqlbuild.executor.testing.main.comparison_sql import build_sql_test_comparison_sql from tests.unit.src.sqlbuild.compiler.planner._helpers.sql_test_assembly._test_types import ( @@ -69,7 +78,54 @@ ) -def assert_native_artifact_matches_python_plan( +def plan_single_test( + *, + test: CompiledSqlTest, + project: CompiledProject, + adapter: BaseAdapter, + sql_analysis_enabled: bool = False, +) -> tuple[SqlTestPlanEntry, tuple[PlanWarning, ...]]: + """Plan one SQL test through the batch planner and return its entry and warnings.""" + + result: SqlTestPlanResult + (result,) = plan_sql_tests( + tests=(test,), + project=project, + adapter=adapter, + sql_analysis_enabled=sql_analysis_enabled, + ) + assert result.entry is not None + return result.entry, result.warnings + + +def plan_single_test_allowing_errors( + *, + test: CompiledSqlTest, + project: CompiledProject, + adapter: BaseAdapter, + sql_analysis_enabled: bool = False, +) -> tuple[SqlTestPlanResult, NativeSqlTestPlan]: + """Plan one SQL test through the batch planner and return its raw native plan too.""" + + result: SqlTestPlanResult + (result,) = plan_sql_tests( + tests=(test,), + project=project, + adapter=adapter, + sql_analysis_enabled=sql_analysis_enabled, + ) + plan: NativeSqlTestPlan + (plan,) = plan_sql_tests_natively( + project=project, + tests=(test,), + adapter=adapter, + sql_analysis_enabled=sql_analysis_enabled, + render_sql=False, + ) + return result, plan + + +def assert_native_artifact_matches_runtime_plan( *, project: CompiledProject, sql_test: CompiledSqlTest, @@ -77,7 +133,7 @@ def assert_native_artifact_matches_python_plan( warnings: tuple[PlanWarning, ...], sql_analysis_enabled: bool, ) -> bool: - """Assert native artifact output remains equivalent to the Python reference path.""" + """Assert the compiled artifact matches the runtime comparison SQL for the same plan.""" adapter: DuckDbAdapter = DuckDbAdapter() expected_sql: str = build_sql_test_comparison_sql( @@ -103,14 +159,7 @@ def assert_native_artifact_matches_python_plan( dialect=adapter.sql_analysis_dialect() ) assert artifact.model_names == tuple(step.model_name for step in entry.chain) - assert artifact.warnings == tuple( - { - "modelName": warning.model_name, - "severity": warning.severity.value, - "message": warning.message, - } - for warning in warnings - ) + assert artifact.error_messages == sql_test_plan_error_messages(warnings=warnings) return True @@ -198,6 +247,10 @@ def build_test_and_project( mock_dbt_ref_names=tuple(test_case.mock_dbt_ref_ctes.keys()), mock_table_function_names=tuple(test_case.mock_table_function_ctes.keys()), expected_model_names=test_case.expected_model_names, + expected_ctes=tuple( + CompileSqlTestCte(name=f"{EXPECTED_TEST_CTE_PREFIX}{name}", sql_body=body) + for name, body in test_case.expected_cte_bodies.items() + ), assertion_ctes=tuple( CompileSqlTestCte( name=f"{ASSERT_TEST_CTE_PREFIX}{name}", diff --git a/tests/unit/src/sqlbuild/compiler/planner/_helpers/sql_test_assembly/test_plan_test.py b/tests/unit/src/sqlbuild/compiler/planner/_helpers/sql_test_assembly/test_plan_test.py index c344596fb1..14cbb7def1 100644 --- a/tests/unit/src/sqlbuild/compiler/planner/_helpers/sql_test_assembly/test_plan_test.py +++ b/tests/unit/src/sqlbuild/compiler/planner/_helpers/sql_test_assembly/test_plan_test.py @@ -2,11 +2,12 @@ from dataclasses import replace from pathlib import Path -from unittest.mock import Mock import pytest +from sqlbuild.adapter.contract.classes.base_adapter import BaseAdapter from sqlbuild.adapters.duckdb.classes.duckdb_adapter import DuckDbAdapter +from sqlbuild.adapters.sqlserver.classes.sqlserver_adapter import SqlServerAdapter from sqlbuild.compiler.compile.exceptions import CompileInputError from sqlbuild.compiler.compile.models import ( CompiledDirectLogicSqlTestPayload, @@ -20,24 +21,26 @@ ) from sqlbuild.compiler.compile.types import CompiledResourceType, SqlTestMode from sqlbuild.compiler.discovery.models import DiscoveredSqlTestBlock, DiscoveredSqlTestFile -from sqlbuild.compiler.planner._helpers.sql_tests import assembly as sql_test_assembly -from sqlbuild.compiler.planner._helpers.sql_tests.assembly import plan_test from sqlbuild.compiler.planner._helpers.sql_tests.native_planning import ( plan_and_render_sql_test_artifacts, + sql_test_plan_error_messages, ) +from sqlbuild.compiler.planner.exceptions import PlannerInputError from sqlbuild.compiler.planner.main.commands._relations import resolve_static_relation_context from sqlbuild.compiler.planner.main.commands._scope import resolve_static_command_scope from sqlbuild.compiler.planner.main.commands.sql_test import build_test_command_plan from sqlbuild.compiler.planner.models import ( ChainStep, + NativeSqlTestPlan, PlannerScope, PlannerSelection, PlanOutput, PlanWarning, - SqlAnalysisResolvedTestSql, SqlTestPlanEntry, + SqlTestPlanResult, ) from sqlbuild.compiler.planner.types import WarningSeverity +from sqlbuild.executor.testing.main.comparison_sql import build_sql_test_comparison_sql from tests.unit.src.sqlbuild.compiler.planner._helpers.sql_test_assembly._test_types import ( AssertionChainCteErrorTestCase, NativePlanningDifferentialTestCase, @@ -47,9 +50,12 @@ SqlAnalysisDialectTestCase, ) from tests.unit.src.sqlbuild.compiler.planner._helpers.sql_test_assembly.helpers import ( - assert_native_artifact_matches_python_plan, + assert_native_artifact_matches_runtime_plan, build_test_and_project, + plan_single_test, + plan_single_test_allowing_errors, ) +from tests.unit.src.sqlbuild.executor.testing.main.helpers import build_comparison_test_adapter @pytest.mark.parametrize( @@ -114,23 +120,6 @@ "payments": "SELECT 1 AS payment_id", }, ), - PlanTestChainTestCase( - description="single model with unmocked dbt ref reports missing mock error", - model_queries={ - "payments": 'SELECT payment_id FROM __dbt_ref("stripe", "payments")', - }, - mock_ref_ctes={}, - mock_source_ctes={}, - helper_ctes={}, - expected_model_names=("payments",), - expected_chain_length=1, - expected_warning_count=1, - expected_warning_severity=WarningSeverity.ERROR, - expected_error_fragments=("__dbt_ref__stripe__payments which has no mock",), - expected_cte_bodies={ - "payments": 'SELECT payment_id FROM __dbt_ref("stripe", "payments")', - }, - ), PlanTestChainTestCase( description="sql test macro mocks override model macro expansion before refs resolve", model_queries={ @@ -255,6 +244,7 @@ expected_model_names=("orders",), expected_chain_length=1, table_function_locations={"customer_orders": "main.customer_orders"}, + expected_cte_bodies={"orders": "SELECT 1 AS order_id"}, expected_sql_fragments={ "orders": "FROM main.customer_orders(42)", }, @@ -277,6 +267,7 @@ expected_model_names=("orders",), expected_chain_length=1, table_function_locations={"customer_orders": "main.customer_orders"}, + expected_cte_bodies={"orders": "SELECT 7 AS order_id"}, expected_sql_fragments={"orders": "SELECT 7 AS order_id"}, unexpected_sql_fragments={ "orders": ("main.customer_orders", "COALESCE", "customer_id"), @@ -439,22 +430,6 @@ "C": "SELECT 1 AS id", }, ), - PlanTestChainTestCase( - description="missing expected model produces error warning", - model_queries={}, - mock_ref_ctes={ - "raw": "SELECT 1 AS id", - }, - mock_source_ctes={}, - helper_ctes={}, - expected_model_names=("nonexistent",), - expected_chain_length=0, - expected_warning_count=2, - expected_warning_severity=None, - expected_cte_bodies={ - "nonexistent": "SELECT 1", - }, - ), PlanTestChainTestCase( description="diamond dependency A to B and C both to D", model_queries={ @@ -478,6 +453,95 @@ "D": "SELECT 1 AS id", }, ), + PlanTestChainTestCase( + description=("model with both mock ref and chain ref resolves both"), + model_queries={ + "stg": 'SELECT id FROM __source("raw")', + "final": ('SELECT a.id FROM __ref("stg") a JOIN __ref("lookup") b ON a.id = b.id'), + }, + mock_ref_ctes={ + "lookup": "SELECT 1 AS id, 'US' AS country", + }, + mock_source_ctes={ + "raw": "SELECT 1 AS id", + }, + helper_ctes={}, + expected_model_names=("stg", "final"), + expected_chain_length=2, + expected_sql_fragments={ + "final": "SELECT 1 AS id", + }, + expected_warning_count=0, + expected_cte_bodies={ + "stg": "SELECT 1 AS id", + "final": "SELECT 1 AS id", + }, + ), + ], + ids=lambda case: case.description, +) +def test_given_test_and_project_when_planning_then_produces_expected_chain( + test_case: PlanTestChainTestCase, +) -> None: + compiled_test: CompiledSqlTest + project: CompiledProject + compiled_test, project = build_test_and_project(test_case) + + entry: SqlTestPlanEntry + warnings: tuple[PlanWarning, ...] + entry, warnings = plan_single_test(test=compiled_test, project=project, adapter=DuckDbAdapter()) + + assert len(entry.chain) == test_case.expected_chain_length + + model_name: str + expected_fragment: str + chain_by_model_name: dict[str, ChainStep] = {step.model_name: step for step in entry.chain} + assert len(chain_by_model_name) == len(entry.chain) + for model_name, expected_fragment in test_case.expected_sql_fragments.items(): + assert expected_fragment in chain_by_model_name[model_name].resolved_sql + for model_name, unexpected_fragments in test_case.unexpected_sql_fragments.items(): + for unexpected_fragment in unexpected_fragments: + assert unexpected_fragment not in chain_by_model_name[model_name].resolved_sql + assertions_by_name: dict[str, str] = { + assertion.name: assertion.resolved_sql for assertion in entry.assertions + } + for assertion_name, expected_fragment in test_case.expected_assertion_fragments.items(): + assert expected_fragment in assertions_by_name[assertion_name] + + assert entry.mock_table_function_names == test_case.expected_mock_table_function_names + assert tuple(dependency.name for dependency in entry.function_deps) == ( + test_case.expected_function_deps + ) + + assert len(warnings) == test_case.expected_warning_count + expected_sev: WarningSeverity | None = test_case.expected_warning_severity + actual_sevs: tuple[WarningSeverity, ...] = tuple(w.severity for w in warnings) + assert (expected_sev is None) or all(s == expected_sev for s in actual_sevs) + expected_error_fragment: str + for expected_error_fragment in test_case.expected_error_fragments: + assert any(expected_error_fragment in w.message for w in warnings) + + +@pytest.mark.parametrize( + "test_case", + [ + PlanTestChainTestCase( + description="single model with unmocked dbt ref reports missing mock error", + model_queries={ + "payments": 'SELECT payment_id FROM __dbt_ref("stripe", "payments")', + }, + mock_ref_ctes={}, + mock_source_ctes={}, + helper_ctes={}, + expected_model_names=("payments",), + expected_chain_length=1, + expected_warning_count=1, + expected_warning_severity=WarningSeverity.ERROR, + expected_error_fragments=("__dbt_ref__stripe__payments which has no mock",), + expected_cte_bodies={ + "payments": 'SELECT payment_id FROM __dbt_ref("stripe", "payments")', + }, + ), PlanTestChainTestCase( description=("unresolved ref produces error warning"), model_queries={ @@ -526,30 +590,6 @@ "orders": "SELECT 1", }, ), - PlanTestChainTestCase( - description=("model with both mock ref and chain ref resolves both"), - model_queries={ - "stg": 'SELECT id FROM __source("raw")', - "final": ('SELECT a.id FROM __ref("stg") a JOIN __ref("lookup") b ON a.id = b.id'), - }, - mock_ref_ctes={ - "lookup": "SELECT 1 AS id, 'US' AS country", - }, - mock_source_ctes={ - "raw": "SELECT 1 AS id", - }, - helper_ctes={}, - expected_model_names=("stg", "final"), - expected_chain_length=2, - expected_sql_fragments={ - "final": "SELECT 1 AS id", - }, - expected_warning_count=0, - expected_cte_bodies={ - "stg": "SELECT 1 AS id", - "final": "SELECT 1 AS id", - }, - ), PlanTestChainTestCase( description=("multiple unresolved refs produce multiple errors"), model_queries={ @@ -566,49 +606,51 @@ "orders": "SELECT 1", }, ), + PlanTestChainTestCase( + description="missing expected model produces error warning", + model_queries={}, + mock_ref_ctes={ + "raw": "SELECT 1 AS id", + }, + mock_source_ctes={}, + helper_ctes={}, + expected_model_names=("nonexistent",), + expected_chain_length=0, + expected_warning_count=2, + expected_warning_severity=None, + expected_cte_bodies={ + "nonexistent": "SELECT 1", + }, + ), ], ids=lambda case: case.description, ) -def test_given_test_and_project_when_planning_then_produces_expected_chain( +def test_given_unresolved_chain_input_when_planning_then_test_is_blocked_with_error_diagnostics( test_case: PlanTestChainTestCase, ) -> None: compiled_test: CompiledSqlTest project: CompiledProject compiled_test, project = build_test_and_project(test_case) - entry: SqlTestPlanEntry - warnings: tuple[PlanWarning, ...] - entry, warnings = plan_test(test=compiled_test, project=project, adapter=DuckDbAdapter()) - - assert len(entry.chain) == test_case.expected_chain_length + result: SqlTestPlanResult + plan: NativeSqlTestPlan + result, plan = plan_single_test_allowing_errors( + test=compiled_test, project=project, adapter=DuckDbAdapter() + ) + assert result.entry is None + assert result.fixture_diagnostics == sql_test_plan_error_messages(warnings=plan.warnings) + assert result.fixture_diagnostics + assert len(plan.chain) == test_case.expected_chain_length + chain_by_model_name: dict[str, ChainStep] = {step.model_name: step for step in plan.chain} model_name: str expected_fragment: str - chain_by_model_name: dict[str, ChainStep] = {step.model_name: step for step in entry.chain} - assert len(chain_by_model_name) == len(entry.chain) for model_name, expected_fragment in test_case.expected_sql_fragments.items(): assert expected_fragment in chain_by_model_name[model_name].resolved_sql - for model_name, unexpected_fragments in test_case.unexpected_sql_fragments.items(): - for unexpected_fragment in unexpected_fragments: - assert unexpected_fragment not in chain_by_model_name[model_name].resolved_sql - assertions_by_name: dict[str, str] = { - assertion.name: assertion.resolved_sql for assertion in entry.assertions - } - for assertion_name, expected_fragment in test_case.expected_assertion_fragments.items(): - assert expected_fragment in assertions_by_name[assertion_name] - - assert entry.mock_table_function_names == test_case.expected_mock_table_function_names - assert tuple(dependency.name for dependency in entry.function_deps) == ( - test_case.expected_function_deps - ) - - assert len(warnings) == test_case.expected_warning_count - expected_sev: WarningSeverity | None = test_case.expected_warning_severity - actual_sevs: tuple[WarningSeverity, ...] = tuple(w.severity for w in warnings) - assert (expected_sev is None) or all(s == expected_sev for s in actual_sevs) + assert len(plan.warnings) == test_case.expected_warning_count expected_error_fragment: str for expected_error_fragment in test_case.expected_error_fragments: - assert any(expected_error_fragment in w.message for w in warnings) + assert any(expected_error_fragment in message for message in result.fixture_diagnostics) @pytest.mark.parametrize( @@ -680,7 +722,7 @@ def test_given_test_and_project_when_planning_then_produces_expected_chain( ), ids=lambda case: case.description, ) -def test_given_model_chain_when_native_planning_then_matches_python_artifact_and_warnings( +def test_given_model_chain_when_native_planning_then_artifact_matches_runtime_sql_and_warnings( test_case: NativePlanningDifferentialTestCase, ) -> None: compiled_test: CompiledSqlTest @@ -702,14 +744,14 @@ def test_given_model_chain_when_native_planning_then_matches_python_artifact_and ) project = replace(project, sql_tests=(compiled_test,)) adapter: DuckDbAdapter = DuckDbAdapter() - entry, warnings = plan_test( + entry, warnings = plan_single_test( test=compiled_test, project=project, adapter=adapter, sql_analysis_enabled=test_case.sql_analysis_enabled, ) assert ( - assert_native_artifact_matches_python_plan( + assert_native_artifact_matches_runtime_plan( project=project, sql_test=compiled_test, entry=entry, @@ -763,7 +805,7 @@ def test_given_refs_in_comments_and_literals_when_planning_then_preserves_sql_an project: CompiledProject compiled_test, project = build_test_and_project(test_case) - entry, warnings = plan_test(test=compiled_test, project=project, adapter=DuckDbAdapter()) + entry, warnings = plan_single_test(test=compiled_test, project=project, adapter=DuckDbAdapter()) assert not warnings assert tuple(step.model_name for step in entry.chain) == test_case.expected_model_names @@ -802,7 +844,7 @@ def test_given_repeated_query_with_different_fixture_rows_when_planning_then_kee compiled_test: CompiledSqlTest project: CompiledProject compiled_test, project = build_test_and_project(fixture_case) - entry, warnings = plan_test( + entry, warnings = plan_single_test( test=compiled_test, project=project, adapter=DuckDbAdapter(), @@ -842,13 +884,18 @@ def test_given_unresolved_marker_in_mock_when_planning_with_analysis_then_report project: CompiledProject compiled_test, project = build_test_and_project(test_case) - _, warnings = plan_test( + result: SqlTestPlanResult + plan: NativeSqlTestPlan + result, plan = plan_single_test_allowing_errors( test=compiled_test, project=project, adapter=DuckDbAdapter(), sql_analysis_enabled=True, ) + warnings: tuple[PlanWarning, ...] = plan.warnings + assert result.entry is None + assert result.fixture_diagnostics == (warnings[0].message,) assert len(warnings) == test_case.expected_warning_count assert warnings[0].severity is test_case.expected_warning_severity assert test_case.expected_error_fragments[0] in warnings[0].message @@ -913,7 +960,7 @@ def test_given_macro_sql_test_when_planning_then_compares_actual_to_expected_dir ), ) - entry, warnings = plan_test( + entry, warnings = plan_single_test( test=sql_test, adapter=DuckDbAdapter(), project=CompiledProject( @@ -1026,7 +1073,7 @@ def test_given_udf_sql_test_when_planning_then_compares_resolved_actual_to_expec ), sql_tests=(sql_test,), ) - entry, warnings = plan_test( + entry, warnings = plan_single_test( test=sql_test, adapter=DuckDbAdapter(), project=project, @@ -1041,7 +1088,7 @@ def test_given_udf_sql_test_when_planning_then_compares_resolved_actual_to_expec assert entry.function_deps == ( CompiledObjectKey(resource_type=CompiledResourceType.UDF, name="format_cents"), ) - assert_native_artifact_matches_python_plan( + assert_native_artifact_matches_runtime_plan( project=project, sql_test=sql_test, entry=entry, @@ -1157,7 +1204,7 @@ def test_given_table_function_sql_test_when_planning_then_compares_resolved_actu ), sql_tests=(sql_test,), ) - entry, warnings = plan_test( + entry, warnings = plan_single_test( test=sql_test, adapter=DuckDbAdapter(), project=project, @@ -1175,7 +1222,7 @@ def test_given_table_function_sql_test_when_planning_then_compares_resolved_actu name="customer_orders", ), ) - assert_native_artifact_matches_python_plan( + assert_native_artifact_matches_runtime_plan( project=project, sql_test=sql_test, entry=entry, @@ -1235,7 +1282,7 @@ def test_given_sql_analysis_enabled_when_planning_test_then_it_uses_top_level_ge entry: SqlTestPlanEntry warnings: tuple[PlanWarning, ...] - entry, warnings = plan_test( + entry, warnings = plan_single_test( test=compiled_test, project=project, adapter=DuckDbAdapter(), @@ -1287,7 +1334,7 @@ def test_given_sql_analysis_enabled_when_generated_cte_name_conflicts_then_it_ra compiled_test, project = build_test_and_project(test_case) with pytest.raises(ValueError, match=test_case.expected_error_fragments[0]): - plan_test( + plan_single_test( test=compiled_test, project=project, adapter=DuckDbAdapter(), @@ -1332,7 +1379,7 @@ def test_given_sql_analysis_assertion_when_planning_then_uses_shared_chain_ctes( entry: SqlTestPlanEntry warnings: tuple[PlanWarning, ...] - entry, warnings = plan_test( + entry, warnings = plan_single_test( test=compiled_test, project=project, adapter=DuckDbAdapter(), @@ -1348,82 +1395,15 @@ def test_given_sql_analysis_assertion_when_planning_then_uses_shared_chain_ctes( assert "FROM (WITH" not in assertion_sql -@pytest.mark.parametrize( - "test_case", - [ - PlanTestChainTestCase( - description="assertion analysis failure uses the independent textual chain", - model_queries={"stg_orders": 'SELECT id AS order_id FROM __source("raw")'}, - mock_ref_ctes={}, - mock_source_ctes={"raw": "SELECT 1 AS id"}, - helper_ctes={}, - expected_model_names=("stg_orders",), - expected_chain_length=1, - expected_cte_bodies={"stg_orders": "SELECT 1 AS order_id"}, - assertion_ctes={ - "order_ids_are_not_null": ( - 'SELECT * FROM __ref("stg_orders") AS stg_orders WHERE order_id IS NULL' - ) - }, - ) - ], - ids=lambda case: case.description, -) -def test_given_assertion_analysis_failure_when_planning_then_textual_chain_stays_valid( - test_case: PlanTestChainTestCase, - monkeypatch: pytest.MonkeyPatch, -) -> None: - compiled_test: CompiledSqlTest - project: CompiledProject - compiled_test, project = build_test_and_project(test_case) - model_analysis_result: SqlAnalysisResolvedTestSql | None = ( - sql_test_assembly.try_resolve_test_model_sql_with_sql_analysis( - query_sql=test_case.model_queries["stg_orders"], - mock_refs={}, - mock_sources=test_case.mock_source_ctes, - mock_seeds={}, - mock_dbt_refs={}, - function_context=sql_test_assembly.build_test_function_analysis_context( - function_locations={} - ), - helper_ctes=(), - resolved_chain={}, - file_label="tests/unit/test_chain.sql", - sql_analysis_dialect="duckdb", - ) - ) - assert model_analysis_result is not None - monkeypatch.setattr( - sql_test_assembly, - "try_resolve_test_model_sql_with_sql_analysis", - Mock(side_effect=(model_analysis_result, None)), - ) - - entry: SqlTestPlanEntry - warnings: tuple[PlanWarning, ...] - entry, warnings = plan_test( - test=compiled_test, - project=project, - adapter=DuckDbAdapter(), - sql_analysis_enabled=True, - ) - - assert not warnings - assert len(entry.chain) == test_case.expected_chain_length - assert len(entry.assertions) == 1 - resolved_assertion_sql: str = entry.assertions[0].resolved_sql - assert "WITH __ref__stg_orders AS" in resolved_assertion_sql - assert "SELECT 1 AS id" in resolved_assertion_sql - assert "__REF(" not in resolved_assertion_sql.upper() - assert "FROM (WITH" not in resolved_assertion_sql.upper() - - @pytest.mark.parametrize( "test_case", [ SqlAnalysisDialectTestCase( description="Snowflake STARTSWITH spelling is preserved", - query_sql="SELECT STARTSWITH(name, 'A') AS matches FROM __ref(\"items\")", + query_sql=( + "WITH picked AS (SELECT STARTSWITH(name, 'A') AS matches " + 'FROM __ref("items")) SELECT matches FROM picked' + ), dialect="snowflake", expected_sql_fragment="STARTSWITH(name, 'A')", expected_absent_sql_fragment="STARTS_WITH", @@ -1434,26 +1414,44 @@ def test_given_assertion_analysis_failure_when_planning_then_textual_chain_stays def test_given_adapter_function_when_resolving_with_analysis_then_emits_supported_spelling( test_case: SqlAnalysisDialectTestCase, ) -> None: - result: SqlAnalysisResolvedTestSql | None = ( - sql_test_assembly.try_resolve_test_model_sql_with_sql_analysis( - query_sql=test_case.query_sql, - mock_refs={"items": "SELECT 'Alice' AS name"}, - mock_sources={}, - mock_seeds={}, - mock_dbt_refs={}, - function_context=sql_test_assembly.build_test_function_analysis_context( - function_locations={} - ), - helper_ctes=(), - resolved_chain={}, - file_label="tests/unit/test_dialect.sql", - sql_analysis_dialect=test_case.dialect, + compiled_test: CompiledSqlTest + project: CompiledProject + compiled_test, project = build_test_and_project( + PlanTestChainTestCase( + description=test_case.description, + model_queries={"matches": test_case.query_sql}, + mock_ref_ctes={"items": "SELECT 'Alice' AS name"}, + mock_source_ctes={}, + helper_ctes={}, + expected_model_names=("matches",), + expected_chain_length=1, + expected_cte_bodies={"matches": "SELECT TRUE AS matches"}, ) ) + assert isinstance(compiled_test.payload, CompiledModelSqlTestPayload) + compiled_test = replace( + compiled_test, + payload=replace( + compiled_test.payload, + expected_ctes=( + CompileSqlTestCte(name="__expected__matches", sql_body="SELECT TRUE AS matches"), + ), + ), + ) + adapter: BaseAdapter = build_comparison_test_adapter(test_case.dialect) - assert result is not None - assert test_case.expected_sql_fragment in result.resolved_sql - assert test_case.expected_absent_sql_fragment not in result.resolved_sql + entry, _ = plan_single_test( + test=compiled_test, project=project, adapter=adapter, sql_analysis_enabled=True + ) + comparison_sql: str = build_sql_test_comparison_sql( + test_entry=entry, + set_difference_operator=adapter.render_set_difference_operator(), + sql_analysis_dialect=adapter.sql_analysis_dialect(), + ) + + assert test_case.expected_sql_fragment in entry.chain[0].resolved_sql + assert test_case.expected_sql_fragment in comparison_sql + assert test_case.expected_absent_sql_fragment not in comparison_sql @pytest.mark.parametrize( @@ -1462,8 +1460,8 @@ def test_given_adapter_function_when_resolving_with_analysis_then_emits_supporte AssertionChainCteErrorTestCase( description="rejects referenced model fallback beginning with with", assertion_sql='SELECT * FROM __ref("stg_orders")', - resolved_chain={ - "stg_orders": "(WITH model_rows AS (SELECT 1 AS id) SELECT * FROM model_rows)" + model_queries={ + "stg_orders": "WITH model_rows AS (SELECT 1 AS id) SELECT * FROM model_rows" }, expected_error_fragment="referenced model beginning with WITH", ), @@ -1473,7 +1471,7 @@ def test_given_adapter_function_when_resolving_with_analysis_then_emits_supporte 'WITH invalid_orders AS (SELECT * FROM __ref("stg_orders")) ' "SELECT * FROM invalid_orders" ), - resolved_chain={"stg_orders": "(SELECT 1 AS id)"}, + model_queries={"stg_orders": "SELECT 1 AS id"}, expected_error_fragment="assertion beginning with WITH", ), ], @@ -1482,9 +1480,19 @@ def test_given_adapter_function_when_resolving_with_analysis_then_emits_supporte def test_given_unflattened_with_when_building_assertion_ctes_then_raises_clear_error( test_case: AssertionChainCteErrorTestCase, ) -> None: - with pytest.raises(ValueError, match=test_case.expected_error_fragment): - sql_test_assembly._build_assertion_chain_ctes( - assertion_sql=test_case.assertion_sql, - resolved_chain=test_case.resolved_chain, - requires_flat_ctes=True, + compiled_test, project = build_test_and_project( + PlanTestChainTestCase( + description=test_case.description, + model_queries=test_case.model_queries, + mock_ref_ctes={}, + mock_source_ctes={}, + helper_ctes={}, + expected_model_names=(), + expected_chain_length=1, + expected_cte_bodies={}, + assertion_ctes={"flattened": test_case.assertion_sql}, ) + ) + + with pytest.raises(PlannerInputError, match=test_case.expected_error_fragment): + plan_single_test(test=compiled_test, project=project, adapter=SqlServerAdapter()) diff --git a/tests/unit/src/sqlbuild/compiler/planner/_helpers/test_fixture_planning_work.py b/tests/unit/src/sqlbuild/compiler/planner/_helpers/test_fixture_planning_work.py index ec8d96b5bb..5e1557d5de 100644 --- a/tests/unit/src/sqlbuild/compiler/planner/_helpers/test_fixture_planning_work.py +++ b/tests/unit/src/sqlbuild/compiler/planner/_helpers/test_fixture_planning_work.py @@ -100,8 +100,8 @@ def test_given_many_tests_when_building_entries_then_project_fixture_metadata_is "analyze_resolved_column_reads", fallback_analyzer, ) - topology_builder: Mock = Mock(wraps=sql_test_assembly._topo_sort_model_chain) - monkeypatch.setattr(sql_test_assembly, "_topo_sort_model_chain", topology_builder) + topology_builder: Mock = Mock(wraps=sql_test_assembly.resolve_sql_test_model_chains) + monkeypatch.setattr(sql_test_assembly, "resolve_sql_test_model_chains", topology_builder) entries: list[SqlTestPlanEntry] warnings: list[PlanWarning] diff --git a/tests/unit/src/sqlbuild/executor/testing/main/test_comparison_sql.py b/tests/unit/src/sqlbuild/executor/testing/main/test_comparison_sql.py index ba4dee9776..1c0fd91d57 100644 --- a/tests/unit/src/sqlbuild/executor/testing/main/test_comparison_sql.py +++ b/tests/unit/src/sqlbuild/executor/testing/main/test_comparison_sql.py @@ -6,7 +6,6 @@ from sqlbuild.adapter.contract.classes.base_adapter import BaseAdapter from sqlbuild.compiler.planner.models import ChainStep, SqlTestPlanEntry -from sqlbuild.executor.testing._helpers import comparison_sql as comparison_sql_helpers from sqlbuild.executor.testing.main.comparison_sql import build_sql_test_comparison_sql from sqlbuild.executor.testing.main.comparison_sql_batch import ( build_sql_test_comparison_sql_batch, @@ -91,58 +90,76 @@ def test_given_adapter_when_building_comparison_sql_then_it_uses_expected_set_di "test_case", [ BuildComparisonSqlTestCase( - description="Snowflake comparison formatting preserves STARTSWITH", + description="Snowflake comparison lifting preserves STARTSWITH", adapter_name="snowflake", - expected_fragments=("STARTSWITH(name, 'A')",), + expected_fragments=("STARTSWITH(name, 'A')", "picked AS ("), expected_absent_fragments=("STARTS_WITH",), ) ], ids=lambda case: case.description, ) -def test_given_snowflake_function_when_formatting_comparison_then_emits_supported_spelling( +def test_given_snowflake_function_when_lifting_comparison_ctes_then_emits_supported_spelling( test_case: BuildComparisonSqlTestCase, ) -> None: adapter: BaseAdapter = build_comparison_test_adapter(test_case.adapter_name) + entry: SqlTestPlanEntry = replace( + build_comparison_test_entry(), + chain=( + ChainStep( + model_name="orders", + resolved_sql=( + "WITH picked AS (SELECT STARTSWITH(name, 'A') AS matches FROM items) " + "SELECT matches FROM picked" + ), + expected_cte_sql="SELECT TRUE AS matches", + ), + ), + ) - formatted_sql: str = comparison_sql_helpers.format_sql( - sql="SELECT STARTSWITH(name, 'A') AS matches FROM items", + comparison_sql: str = build_sql_test_comparison_sql( + test_entry=entry, + set_difference_operator=adapter.render_set_difference_operator(), sql_analysis_dialect=adapter.sql_analysis_dialect(), ) for expected_fragment in test_case.expected_fragments: - assert expected_fragment in formatted_sql + assert expected_fragment in comparison_sql for expected_absent_fragment in test_case.expected_absent_fragments: - assert expected_absent_fragment not in formatted_sql + assert expected_absent_fragment not in comparison_sql @pytest.mark.parametrize( "test_case", [ BuildComparisonSqlTestCase( - description="disabled SQL analysis avoids parser formatting", + description="disabled SQL analysis keeps step SQL verbatim", adapter_name="duckdb", expected_fragments=( - "SELECT 1 AS order_id", - "SELECT 2 AS order_id", + "__actual__orders AS (with Picked as (select 1 as order_id) select * from Picked)", + "__expected__orders AS (SELECT 2 AS order_id)", ), sql_analysis_enabled=False, ) ], ids=lambda case: case.description, ) -def test_given_formatting_disabled_when_building_comparison_sql_then_it_does_not_import_polyglot( +def test_given_sql_analysis_disabled_when_building_comparison_sql_then_step_sql_is_not_regenerated( test_case: BuildComparisonSqlTestCase, - monkeypatch: pytest.MonkeyPatch, ) -> None: adapter: BaseAdapter = build_comparison_test_adapter(test_case.adapter_name) - monkeypatch.setattr( - comparison_sql_helpers, - "import_polyglot_sql", - lambda: pytest.fail("polyglot should not be imported when disabled"), + entry: SqlTestPlanEntry = replace( + build_comparison_test_entry(sql_analysis_enabled=test_case.sql_analysis_enabled), + chain=( + ChainStep( + model_name="orders", + resolved_sql="with Picked as (select 1 as order_id) select * from Picked", + expected_cte_sql="SELECT 2 AS order_id", + ), + ), ) comparison_sql: str = build_sql_test_comparison_sql( - test_entry=build_comparison_test_entry(sql_analysis_enabled=test_case.sql_analysis_enabled), + test_entry=entry, set_difference_operator=adapter.render_set_difference_operator(), sql_analysis_dialect=adapter.sql_analysis_dialect(), ) @@ -340,10 +357,10 @@ def test_given_preanalyzed_step_with_authored_cte_when_building_comparison_then_ @pytest.mark.parametrize( "test_case", - [ExpectedBooleanTestCase(description="native batch matches reference", expected_result=True)], + [ExpectedBooleanTestCase(description="batch matches single renders", expected_result=True)], ids=lambda case: case.description, ) -def test_given_representative_plans_when_rendering_native_batch_then_matches_reference_sql( +def test_given_representative_plans_when_rendering_batch_then_matches_single_renders( test_case: ExpectedBooleanTestCase, ) -> None: entries: tuple[SqlTestPlanEntry, ...] = ( @@ -370,7 +387,7 @@ def test_given_representative_plans_when_rendering_native_batch_then_matches_ref [ExpectedBooleanTestCase(description="quoted table function matches", expected_result=True)], ids=lambda case: case.description, ) -def test_given_quoted_table_function_when_rendering_native_batch_then_matches_reference_sql( +def test_given_quoted_table_function_when_rendering_batch_then_matches_single_render( test_case: ExpectedBooleanTestCase, ) -> None: entry: SqlTestPlanEntry = build_table_function_test_entry(