diff --git a/crates/sqlbuild-analysis/src/compiler/_helpers/sql_tests/assembly.rs b/crates/sqlbuild-analysis/src/compiler/_helpers/sql_tests/assembly.rs new file mode 100644 index 0000000000..6836d3cf0f --- /dev/null +++ b/crates/sqlbuild-analysis/src/compiler/_helpers/sql_tests/assembly.rs @@ -0,0 +1,173 @@ +//! Python's `_assemble_compiled_sql_test` facts: name, targets, scope, case identity and P013s. + +use rayon::prelude::{IntoParallelRefIterator, ParallelIterator}; +use sqlbuild_sqltext::sql_scan::models::LexicalSyntax; + +use crate::compiler::_helpers::sql_tests::case_identity::case_fingerprint; +use crate::compiler::_helpers::sql_tests::macro_call_names::macro_call_names; +use crate::compiler::_helpers::sql_tests::mock_reads::{ + MockReadsRequest, mock_reading_helper_diagnostics, +}; +use crate::compiler::models::{ + AssembledSqlTestFacts, SqlTestAssemblyBatch, SqlTestAssemblyDeferral, SqlTestAssemblyModel, + SqlTestAssemblyOutcome, SqlTestAssemblyPayload, SqlTestAssemblyTest, SqlTestHelperDiagnostic, +}; + +const MODEL_RESOURCE: &str = "model"; +const TABLE_FUNCTION_RESOURCE: &str = "table_fn"; +const MACRO_MODE: &str = "macro"; +const UDF_MODE: &str = "udf"; +const UDF_REFERENCE_KIND: &str = "udf"; + +/// Each test's facts in request order, or the reason Python assembles it. +pub(crate) fn assemble_batch(batch: &SqlTestAssemblyBatch) -> Vec { + let tests_macros = batch.tests.iter().any(|test| { + matches!(&test.payload, SqlTestAssemblyPayload::Direct { mode, .. } if mode == MACRO_MODE) + }); + let macro_names: Vec = batch + .models + .iter() + .map(|model| match &model.unscanned_macro_source { + Some(sql) if tests_macros => macro_call_names(sql), + Some(_) => Ok(Vec::new()), + None => Ok(model.macro_deps.clone()), + }) + .collect(); + let project = Project { + models: &batch.models, + macro_names: ¯o_names, + syntax: &batch.lexical_syntax, + }; + batch + .tests + .par_iter() + .map(|test| match assemble(test, &project) { + Ok(facts) => SqlTestAssemblyOutcome::Assembled(facts), + Err(deferral) => SqlTestAssemblyOutcome::Deferred(deferral), + }) + .collect() +} + +/// A model's macro dependencies, or Python's scan of its pre-macro SQL when none were recorded. +type MacroNames = Result, SqlTestAssemblyDeferral>; + +struct Project<'a> { + models: &'a [SqlTestAssemblyModel], + macro_names: &'a [MacroNames], + syntax: &'a LexicalSyntax, +} + +fn assemble( + test: &SqlTestAssemblyTest, + project: &Project<'_>, +) -> Result { + let models = project.models; + let syntax = project.syntax; + let mut target_model_names: Vec = Vec::new(); + let mut tested_resources: Vec<(String, String)> = Vec::new(); + let mut diagnostics: Vec = Vec::new(); + let scope_deps: Vec<(&'static str, String)> = match &test.payload { + SqlTestAssemblyPayload::Direct { + mode, + tested_resource_names, + } => { + tested_resources = tested_resource_names + .iter() + .map(|name| (mode.clone(), name.clone())) + .collect(); + direct_scope_deps(mode, tested_resource_names, project)? + } + SqlTestAssemblyPayload::Model(payload) => { + if payload.has_macro_mocks { + return Err(SqlTestAssemblyDeferral::MacroMocks); + } + for name in payload + .expected_model_names + .iter() + .chain(&payload.assertion_target_model_names) + .chain(&payload.reference_target_model_names) + { + if !target_model_names.contains(name) { + target_model_names.push(name.clone()); + } + } + diagnostics = mock_reading_helper_diagnostics(&MockReadsRequest { + test, + payload, + models, + target_model_names: &target_model_names, + syntax, + })?; + target_model_names + .iter() + .map(|name| (MODEL_RESOURCE, name.clone())) + .collect() + } + }; + let case_fingerprint = match &test.case_name { + Some(case_name) => Some(case_fingerprint( + test, + case_name, + &scope_deps, + &tested_resources, + )?), + None => None, + }; + Ok(AssembledSqlTestFacts { + name: test_name(test), + scope_deps, + target_model_names, + case_fingerprint, + diagnostic_resource_name: non_empty(&test.block_name) + .unwrap_or(&test.relative_stem) + .to_owned(), + diagnostics, + }) +} + +fn non_empty(name: &Option) -> Option<&str> { + name.as_deref().filter(|name| !name.is_empty()) +} + +/// Python's `_resolve_test_name`. +fn test_name(test: &SqlTestAssemblyTest) -> String { + let parent = non_empty(&test.block_name).unwrap_or(&test.file_stem); + match &test.case_name { + Some(case_name) => format!("{parent} [{case_name}]"), + None => parent.to_owned(), + } +} + +/// Python's macro, UDF and table-function scope dependencies of a direct-logic test. +fn direct_scope_deps( + mode: &str, + tested_names: &[String], + project: &Project<'_>, +) -> Result, SqlTestAssemblyDeferral> { + let mut scope_deps: Vec<(&'static str, String)> = Vec::new(); + match mode { + MACRO_MODE => { + for (model, names) in project.models.iter().zip(project.macro_names) { + let names = names.as_ref().map_err(|deferral| *deferral)?; + if names.iter().any(|name| tested_names.contains(name)) { + scope_deps.push((MODEL_RESOURCE, model.name.clone())); + } + } + } + UDF_MODE => { + for model in project.models { + if model.references.iter().any(|reference| { + reference.kind == UDF_REFERENCE_KIND && tested_names.contains(&reference.name) + }) { + scope_deps.push((MODEL_RESOURCE, model.name.clone())); + } + } + } + _ => scope_deps.extend( + tested_names + .iter() + .map(|name| (TABLE_FUNCTION_RESOURCE, name.clone())), + ), + } + Ok(scope_deps) +} diff --git a/crates/sqlbuild-analysis/src/compiler/_helpers/sql_tests/case_identity.rs b/crates/sqlbuild-analysis/src/compiler/_helpers/sql_tests/case_identity.rs new file mode 100644 index 0000000000..4bbc98f63f --- /dev/null +++ b/crates/sqlbuild-analysis/src/compiler/_helpers/sql_tests/case_identity.rs @@ -0,0 +1,168 @@ +//! Python's `build_sql_test_case_fingerprint`: the version identity of one expanded test case. + +use sha2::{Digest, Sha256}; +use sqlbuild_core::json::main::dumps::dumps; +use sqlbuild_core::json::models::{JsonDialect, JsonInteger, JsonValue, StdlibJsonOptions}; + +use crate::compiler::models::{ + SqlTestAssemblyDeferral, SqlTestAssemblyTest, SqlTestParameterValue, +}; + +/// Python's default decimal context: precision and exponent limits `normalize()` applies. +const DECIMAL_PRECISION: usize = 28; +const DECIMAL_EMAX: i64 = 999_999; +const DECIMAL_EMIN: i64 = -999_999; + +/// The SHA-256 of Python's compact, ASCII, key-sorted JSON of the case's compile inputs. +pub(crate) fn case_fingerprint( + test: &SqlTestAssemblyTest, + case_name: &str, + scope_deps: &[(&'static str, String)], + tested_resources: &[(String, String)], +) -> Result { + let mut sorted_deps: Vec<(&str, &str)> = scope_deps + .iter() + .map(|(kind, name)| (*kind, name.as_str())) + .collect(); + sorted_deps.sort_unstable(); + let mut sorted_resources: Vec<(&str, &str)> = tested_resources + .iter() + .map(|(kind, name)| (kind.as_str(), name.as_str())) + .collect(); + sorted_resources.sort_unstable(); + let parameter_values: Vec = test + .parameter_values + .iter() + .map(|(name, value)| Ok(JsonValue::Array(vec![string(name), identity(value)?]))) + .collect::>()?; + let payload = JsonValue::Object(vec![ + ("source_path".to_owned(), string(&test.relative_path)), + ("block_index".to_owned(), integer(test.block_index)), + ("case_name".to_owned(), string(case_name)), + ( + "parameter_schema".to_owned(), + JsonValue::Array( + test.parameter_schema + .iter() + .map(|(name, kind, nullable)| { + JsonValue::Array(vec![ + string(name), + string(kind), + JsonValue::Bool(*nullable), + ]) + }) + .collect(), + ), + ), + ( + "parameter_values".to_owned(), + JsonValue::Array(parameter_values), + ), + ("expanded_sql".to_owned(), string(&test.sql_body)), + ("scope_deps".to_owned(), pairs(&sorted_deps)), + ("tested_resources".to_owned(), pairs(&sorted_resources)), + ]); + let dialect = JsonDialect::Stdlib(StdlibJsonOptions { + indent: None, + item_separator: ",".to_owned(), + key_separator: ":".to_owned(), + ensure_ascii: true, + sort_keys: true, + allow_nan: true, + }); + let encoded = + dumps(&payload, &dialect).map_err(|_| SqlTestAssemblyDeferral::UnencodableValue)?; + Ok(Sha256::digest(encoded.as_bytes()) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect()) +} + +fn string(text: &str) -> JsonValue { + JsonValue::String(text.to_owned()) +} + +fn integer(value: i64) -> JsonValue { + JsonInteger::parse(&value.to_string()).map_or(JsonValue::Null, JsonValue::Integer) +} + +fn pairs(entries: &[(&str, &str)]) -> JsonValue { + JsonValue::Array( + entries + .iter() + .map(|(first, second)| JsonValue::Array(vec![string(first), string(second)])) + .collect(), + ) +} + +/// Python's `sql_value_identity`: `(kind, payload)` with decimals as normalized tuples. +fn identity(value: &SqlTestParameterValue) -> Result { + let (kind, payload) = match value { + SqlTestParameterValue::String(text) => ("string", string(text)), + SqlTestParameterValue::Integer(number) => ("integer", integer(*number)), + SqlTestParameterValue::Boolean(flag) => ("boolean", JsonValue::Bool(*flag)), + SqlTestParameterValue::Float(number) => ("float", JsonValue::Float(*number)), + SqlTestParameterValue::Decimal { + negative, + digits, + exponent, + } => ("decimal", normalized_decimal(*negative, digits, *exponent)?), + SqlTestParameterValue::Null => ("null", JsonValue::Null), + SqlTestParameterValue::List(items) => ("list", identities(items)?), + SqlTestParameterValue::Set(items) => ("set", identities(items)?), + SqlTestParameterValue::Object(entries) => ( + "object", + JsonValue::Array( + entries + .iter() + .map(|(key, item)| Ok(JsonValue::Array(vec![string(key), identity(item)?]))) + .collect::>()?, + ), + ), + }; + Ok(JsonValue::Array(vec![string(kind), payload])) +} + +fn identities(items: &[SqlTestParameterValue]) -> Result { + Ok(JsonValue::Array( + items.iter().map(identity).collect::>()?, + )) +} + +/// `Decimal.normalize().as_tuple()` where the default context neither rounds nor clamps. +fn normalized_decimal( + negative: bool, + digits: &[u8], + exponent: i64, +) -> Result { + let sign = integer(i64::from(negative)); + let first = digits.iter().position(|digit| *digit != 0); + let Some(first) = first else { + return Ok(JsonValue::Array(vec![ + sign, + JsonValue::Array(vec![integer(0)]), + integer(0), + ])); + }; + let significant = &digits[first..]; + let adjusted = exponent + i64::try_from(significant.len()).unwrap_or(i64::MAX) - 1; + if significant.len() > DECIMAL_PRECISION || !(DECIMAL_EMIN..=DECIMAL_EMAX).contains(&adjusted) { + return Err(SqlTestAssemblyDeferral::DecimalContext); + } + let mut end = significant.len(); + let mut normalized_exponent = exponent; + while significant[end - 1] == 0 && normalized_exponent < DECIMAL_EMAX { + end -= 1; + normalized_exponent += 1; + } + Ok(JsonValue::Array(vec![ + sign, + JsonValue::Array( + significant[..end] + .iter() + .map(|digit| integer(i64::from(*digit))) + .collect(), + ), + integer(normalized_exponent), + ])) +} diff --git a/crates/sqlbuild-analysis/src/compiler/_helpers/sql_tests/macro_call_names.rs b/crates/sqlbuild-analysis/src/compiler/_helpers/sql_tests/macro_call_names.rs new file mode 100644 index 0000000000..38c0421c61 --- /dev/null +++ b/crates/sqlbuild-analysis/src/compiler/_helpers/sql_tests/macro_call_names.rs @@ -0,0 +1,105 @@ +//! Python's `find_macro_call_names`: unique top-level macro call names in encounter order. + +use sqlbuild_sqltext::sql_scan::main::matching_paren::matching_paren; +use sqlbuild_sqltext::sql_scan::main::non_code_end::non_code_end; +use sqlbuild_sqltext::sql_scan::models::QuotePolicy; + +use crate::compiler::models::SqlTestAssemblyDeferral; + +/// Declaration calls that expansion substitutes rather than running as macros. +const DECLARATION_CALLS: [&str; 2] = ["enum", "const"]; + +type Scan = Result; + +/// The names Python's scan returns; text it raises on, or classifies by Unicode, is Python's. +pub(crate) fn macro_call_names(sql: &str) -> Scan> { + let bytes = sql.as_bytes(); + let mut names: Vec = Vec::new(); + let mut index = 0; + while index < bytes.len() { + match non_code_end(bytes, index, QuotePolicy::COMPILER) { + Ok(Some(end)) => { + index = end; + continue; + } + Ok(None) => {} + Err(_) => return Err(SqlTestAssemblyDeferral::MacroCallScan), + } + if bytes[index] != b'@' || !macro_call_at(bytes, index)? { + index += 1; + continue; + } + let name_end = identifier_end(bytes, index + 1)?; + let name = &sql[index + 1..name_end]; + if !names.iter().any(|known| known == name) { + names.push(name.to_owned()); + } + let open = space_end(bytes, name_end)?; + if bytes.get(open) != Some(&b'(') { + return Err(SqlTestAssemblyDeferral::MacroCallScan); + } + index = matching_paren(bytes, open, QuotePolicy::COMPILER) + .map_err(|_| SqlTestAssemblyDeferral::MacroCallScan)? + + 1; + } + names.retain(|name| !DECLARATION_CALLS.contains(&name.as_str())); + Ok(names) +} + +/// Python's `_is_macro_call_start`, which skips whitespace after each name character. +fn macro_call_at(bytes: &[u8], at: usize) -> Scan { + let Some(&first) = bytes.get(at + 1) else { + return Ok(false); + }; + if !ascii(first)? || !(first == b'_' || first.is_ascii_alphabetic()) { + return Ok(false); + } + let mut cursor = at + 2; + while let Some(&byte) = bytes.get(cursor) { + if !ascii(byte)? || !is_identifier_continue(byte) { + break; + } + cursor = space_end(bytes, cursor + 1)?; + } + Ok(bytes.get(cursor) == Some(&b'(')) +} + +fn identifier_end(bytes: &[u8], start: usize) -> Scan { + let mut cursor = start; + while let Some(&byte) = bytes.get(cursor) { + if !ascii(byte)? || !is_identifier_continue(byte) { + break; + } + cursor += 1; + } + Ok(cursor) +} + +/// Python's `str.isspace` skip; a non-ASCII character there is classified by Python. +fn space_end(bytes: &[u8], start: usize) -> Scan { + let mut cursor = start; + while let Some(&byte) = bytes.get(cursor) { + if !ascii(byte)? + || !matches!( + byte, + b' ' | b'\t' | b'\n' | b'\r' | 0x0b | 0x0c | 0x1c..=0x1f + ) + { + break; + } + cursor += 1; + } + Ok(cursor) +} + +fn is_identifier_continue(byte: u8) -> bool { + byte == b'_' || byte.is_ascii_alphanumeric() +} + +fn ascii(byte: u8) -> Scan { + if byte.is_ascii() { + Ok(true) + } else { + Err(SqlTestAssemblyDeferral::MacroCallScan) + } +} diff --git a/crates/sqlbuild-analysis/src/compiler/_helpers/sql_tests/mock_reads.rs b/crates/sqlbuild-analysis/src/compiler/_helpers/sql_tests/mock_reads.rs new file mode 100644 index 0000000000..f381916286 --- /dev/null +++ b/crates/sqlbuild-analysis/src/compiler/_helpers/sql_tests/mock_reads.rs @@ -0,0 +1,621 @@ +//! Python's `report_mocks_reading_referencing_helpers`: P013s for mocks reading referencing helpers. + +use std::collections::HashSet; + +use polyglot_sql::{ + ComplexityGuardOptions, Dialect, DialectType, Expression, ExpressionWalk, ParseOptions, +}; +use serde_json::Value; +use sqlbuild_sqltext::sql_references::main::extract_sql_references::extract_sql_references; +use sqlbuild_sqltext::sql_references::models::{ReferenceExtraction, SqlReference}; +use sqlbuild_sqltext::sql_scan::models::LexicalSyntax; + +use crate::compiler::_helpers::sql_tests::planning::MOCK_CTE_PREFIXES; +use crate::compiler::models::{ + SqlTestAssemblyDeferral, SqlTestAssemblyModel, SqlTestAssemblyModelPayload, + SqlTestAssemblyReference, SqlTestAssemblyTest, SqlTestCte, SqlTestHelperDiagnostic, +}; + +const REF_KIND: &str = "ref"; +const TABLE_FUNCTION_KIND: &str = "table_fn"; +const DBT_REF_KIND: &str = "dbt_ref"; +const RELATION_KINDS: [&str; 5] = [ + REF_KIND, + "source", + "seed", + DBT_REF_KIND, + TABLE_FUNCTION_KIND, +]; +/// Payload nesting past which the parsed tree is left to Python's `to_dict` walk. +const MAX_TREE_DEPTH: usize = 400; +/// The function call depth SQLBuild's Polyglot proxy allows `parse_one`. +const MAX_FUNCTION_CALL_DEPTH: usize = 512; + +type Deferrable = Result; + +/// A reference as compile carries it: the scanner's or a model input's. +#[derive(Clone)] +struct Reference { + kind: String, + name: String, + package: Option, +} + +/// One model test whose mocks may read referencing helpers, with the project's models. +pub(crate) struct MockReadsRequest<'a> { + pub(crate) test: &'a SqlTestAssemblyTest, + pub(crate) payload: &'a SqlTestAssemblyModelPayload, + pub(crate) models: &'a [SqlTestAssemblyModel], + pub(crate) target_model_names: &'a [String], + pub(crate) syntax: &'a LexicalSyntax, +} + +/// A Python dict by string key: a repeated key keeps its first position and takes the new value. +struct OrderedEntries { + entries: Vec<(String, T)>, +} + +impl OrderedEntries { + fn new() -> Self { + Self { + entries: Vec::new(), + } + } + + fn assign(&mut self, key: String, value: T) { + match self + .entries + .iter_mut() + .find(|(candidate, _)| *candidate == key) + { + Some(entry) => entry.1 = value, + None => self.entries.push((key, value)), + } + } + + fn get(&self, key: &str) -> Option<&T> { + self.entries + .iter() + .find(|(candidate, _)| candidate == key) + .map(|(_, value)| value) + } + + fn keys(&self) -> Vec { + self.entries.iter().map(|(key, _)| key.clone()).collect() + } +} + +/// Python's `SqlTestCteGraph`, keyed by case-folded CTE name in first-definition order. +struct CteGraph<'a> { + ctes: OrderedEntries<&'a SqlTestCte>, + reads: OrderedEntries>, + reader_reads: Vec, +} + +/// A located span as Python's `reference_call_location` reports it. +struct Span { + line: usize, + column: usize, + end_line: usize, + end_column: usize, +} + +impl CteGraph<'_> { + fn cte(&self, key: &str) -> Option<&SqlTestCte> { + self.ctes.get(key).copied() + } + + fn reads(&self, key: &str) -> &[String] { + self.reads.get(key).map_or(&[], Vec::as_slice) + } + + /// Python's `reachable_cte_keys`: breadth first, roots included, in visit order. + fn reachable(&self, roots: &[String]) -> Vec { + let mut pending: Vec = Vec::new(); + for root in roots { + if self.cte(root).is_some() { + pending.push(root.clone()); + } + } + let mut visited: Vec = Vec::new(); + for key in &pending { + if !visited.contains(key) { + visited.push(key.clone()); + } + } + let mut cursor = 0; + while cursor < pending.len() { + let key = pending[cursor].clone(); + cursor += 1; + for dependency in self.reads(&key) { + if !visited.contains(dependency) { + visited.push(dependency.clone()); + pending.push(dependency.clone()); + } + } + } + visited + } +} + +/// The P013 diagnostics Python reports for one model test, in report order. +pub(crate) fn mock_reading_helper_diagnostics( + request: &MockReadsRequest<'_>, +) -> Deferrable> { + let payload = request.payload; + let mut referencing: OrderedEntries = OrderedEntries::new(); + for cte in &payload.authored_ctes { + if is_mock_name(&cte.name) { + continue; + } + let first = references(&cte.sql_body, request.syntax)? + .into_iter() + .find(|reference| RELATION_KINDS.contains(&reference.kind.as_str())); + if let Some(reference) = first { + referencing.assign(ascii_fold(&cte.name)?, reference); + } + } + if referencing.entries.is_empty() { + return Ok(Vec::new()); + } + require_ascii(request.test, payload)?; + let readers: Vec<&SqlTestCte> = payload + .expected_ctes + .iter() + .chain(&payload.assertion_ctes) + .collect(); + let graph = cte_graph(&payload.authored_ctes, &readers)?; + let mut scanned: Vec<&str> = readers.iter().map(|cte| cte.sql_body.as_str()).collect(); + for key in read_helper_keys(&graph) { + if let Some(cte) = graph.cte(&key) { + scanned.push(cte.sql_body.as_str()); + } + } + let mut called_mocks: Vec = Vec::new(); + for sql in scanned { + for reference in references(sql, request.syntax)? { + called_mocks.extend(mock_keys(&reference)?); + } + } + let used = used_mock_keys(request, &graph, &called_mocks)?; + let mut diagnostics: Vec = Vec::new(); + for mock_key in used { + let mut helper: Option<(String, &Reference)> = None; + for key in graph.reachable(graph.reads(&mock_key)) { + if let Some(reference) = referencing.get(&key) { + helper = Some((key, reference)); + break; + } + } + let Some((helper_key, reference)) = helper else { + continue; + }; + let (Some(mock), Some(helper)) = (graph.cte(&mock_key), graph.cte(&helper_key)) else { + continue; + }; + let call = reference_call(reference); + let span = located(request.test, &helper.name, &call); + diagnostics.push(SqlTestHelperDiagnostic { + line: span.line, + column: span.column, + end_line: span.end_line, + end_column: span.end_column, + message: format!( + "SQL test mock '{}' reads helper CTE '{}', which calls {call}; mocks and \ + fixtures are defined before the models the test runs, so the helper cannot be \ + resolved for them", + mock.name, helper.name + ), + help: mock_reference_help(&mock.name, &call, reference), + }); + } + Ok(diagnostics) +} + +fn is_mock_name(name: &str) -> bool { + MOCK_CTE_PREFIXES + .iter() + .any(|prefix| name.starts_with(prefix)) +} + +/// Python's `str.casefold`, exact only on ASCII text. +fn ascii_fold(text: &str) -> Deferrable { + if text.is_ascii() { + Ok(text.to_ascii_lowercase()) + } else { + Err(SqlTestAssemblyDeferral::NonAsciiText) + } +} + +/// Python's folding, token and header patterns read only ASCII text exactly. +fn require_ascii( + test: &SqlTestAssemblyTest, + payload: &SqlTestAssemblyModelPayload, +) -> Deferrable<()> { + let ascii = test.contents.is_ascii() + && test.block_sql.is_ascii() + && payload + .authored_ctes + .iter() + .chain(&payload.expected_ctes) + .chain(&payload.assertion_ctes) + .all(|cte| cte.name.is_ascii() && cte.sql_body.is_ascii()); + if ascii { + Ok(()) + } else { + Err(SqlTestAssemblyDeferral::NonAsciiText) + } +} + +/// Python's `scan_sql_reference_calls(...).references`; a failing scan is Python's to raise. +fn references(sql: &str, syntax: &LexicalSyntax) -> Deferrable> { + match extract_sql_references(sql, syntax) { + ReferenceExtraction::Extracted(scan) => { + Ok(scan.references.into_iter().map(scanned).collect()) + } + ReferenceExtraction::Failed(_) | ReferenceExtraction::Deferred => { + Err(SqlTestAssemblyDeferral::ReferenceScan) + } + } +} + +fn scanned(reference: SqlReference) -> Reference { + Reference { + kind: reference.kind.to_owned(), + name: reference.name, + package: reference.package, + } +} + +fn model_reference(reference: &SqlTestAssemblyReference) -> Reference { + Reference { + kind: reference.kind.clone(), + name: reference.name.clone(), + package: reference.package.clone(), + } +} + +/// Python's `sql_test_cte_graph`. +fn cte_graph<'a>(authored: &'a [SqlTestCte], readers: &[&SqlTestCte]) -> Deferrable> { + let mut ctes: OrderedEntries<&'a SqlTestCte> = OrderedEntries::new(); + for cte in authored { + ctes.assign(ascii_fold(&cte.name)?, cte); + } + let keys: Vec = ctes.keys(); + let mut reads: OrderedEntries> = OrderedEntries::new(); + for (key, cte) in &ctes.entries { + let others: Vec = keys.iter().filter(|other| *other != key).cloned().collect(); + let cte_reads = match parsed_reads(&cte.sql_body, &others)? { + Some(parsed) => parsed, + None => token_reads(&cte.sql_body, &others), + }; + reads.assign(key.clone(), cte_reads); + } + let mut reader_reads: Vec = Vec::new(); + for reader in readers { + let parsed = match parsed_reads(&reader.sql_body, &keys)? { + Some(parsed) => parsed, + None => token_reads(&reader.sql_body, &keys), + }; + for key in parsed { + if !reader_reads.contains(&key) { + reader_reads.push(key); + } + } + } + Ok(CteGraph { + ctes, + reads, + reader_reads, + }) +} + +/// Python's `_parsed_reads`: unqualified reads of `keys` no nested CTE shadows, or None unparsed. +fn parsed_reads(sql: &str, keys: &[String]) -> Deferrable>> { + let folded = ascii_fold(sql)?; + if !keys.iter().any(|key| folded.contains(key.as_str())) { + return Ok(Some(Vec::new())); + } + let options = proxy_parse_options()?; + let Ok(mut statements) = Dialect::get(DialectType::Generic).parse_with_options(sql, &options) + else { + return Ok(None); + }; + if statements.len() != 1 { + return Ok(None); + } + let parsed: Expression = statements.remove(0); + let nested = defined_cte_keys(&parsed)?; + let mut reads: Vec = Vec::new(); + for node in parsed.dfs() { + let Expression::Table(table) = node else { + continue; + }; + if table.schema.is_some() || table.catalog.is_some() { + continue; + } + let key = ascii_fold(&table.name.name)?; + if keys.contains(&key) && !nested.contains(&key) && !reads.contains(&key) { + reads.push(key); + } + } + Ok(Some(reads)) +} + +/// The parse options SQLBuild's Polyglot proxy gives `parse_one`. +fn proxy_parse_options() -> Deferrable { + let guard: ComplexityGuardOptions = serde_json::from_value(serde_json::json!({ + "maxFunctionCallDepth": MAX_FUNCTION_CALL_DEPTH, + })) + .map_err(|_| SqlTestAssemblyDeferral::UnreadableTree)?; + Ok(ParseOptions { + complexity_guard: Some(guard), + }) +} + +/// Python's `_defined_cte_keys` over the serialized tree Python's `to_dict` returns. +fn defined_cte_keys(parsed: &Expression) -> Deferrable> { + let tree = serde_json::to_value(parsed).map_err(|_| SqlTestAssemblyDeferral::UnreadableTree)?; + let mut keys: HashSet = HashSet::new(); + let mut pending: Vec<(&Value, usize)> = vec![(&tree, 0)]; + while let Some((value, depth)) = pending.pop() { + if depth > MAX_TREE_DEPTH { + return Err(SqlTestAssemblyDeferral::UnreadableTree); + } + match value { + Value::Array(items) => pending.extend(items.iter().map(|item| (item, depth + 1))), + Value::Object(entries) => { + if let Some(Value::Array(ctes)) = entries.get("ctes") { + for cte in ctes { + if let Some(Value::String(name)) = cte + .get("alias") + .filter(|alias| alias.is_object()) + .and_then(|alias| alias.get("name")) + { + let _ = keys.insert(ascii_fold(name)?); + } + } + } + pending.extend(entries.values().map(|item| (item, depth + 1))); + } + _ => {} + } + } + Ok(keys) +} + +/// Python's `_token_reads` over `[A-Za-z_][\w$]*` tokens of ASCII text. +fn token_reads(sql: &str, keys: &[String]) -> Vec { + let bytes = sql.as_bytes(); + let mut tokens: Vec = Vec::new(); + let mut index = 0; + while index < bytes.len() { + if !(bytes[index].is_ascii_alphabetic() || bytes[index] == b'_') { + index += 1; + continue; + } + let start = index; + index += 1; + while index < bytes.len() + && (bytes[index].is_ascii_alphanumeric() || matches!(bytes[index], b'_' | b'$')) + { + index += 1; + } + let token = sql[start..index].to_ascii_lowercase(); + if !tokens.contains(&token) { + tokens.push(token); + } + } + tokens.retain(|token| keys.contains(token)); + tokens +} + +/// Python's `_read_helper_keys`: non-mock CTEs the test's readers reach. +fn read_helper_keys(graph: &CteGraph<'_>) -> Vec { + graph + .reachable(&graph.reader_reads) + .into_iter() + .filter(|key| graph.cte(key).is_some_and(|cte| !is_mock_name(&cte.name))) + .collect() +} + +/// Python's `_mock_keys`: the case-folded mock CTE names that stand in for `reference`. +fn mock_keys(reference: &Reference) -> Deferrable> { + let prefix = match reference.kind.as_str() { + "ref" => "__ref__", + "source" => "__source__", + "seed" => "__seed__", + "dbt_ref" => "__dbt_ref__", + "table_fn" => "__table_fn__", + _ => return Ok(Vec::new()), + }; + let mut names: Vec = vec![reference.name.clone()]; + if let Some(package) = &reference.package { + names.push(format!("{package}__{}", reference.name)); + } + names + .iter() + .map(|name| ascii_fold(&format!("{prefix}{name}"))) + .collect() +} + +/// Python's `_used_mock_keys`. +fn used_mock_keys( + request: &MockReadsRequest<'_>, + graph: &CteGraph<'_>, + called_mocks: &[String], +) -> Deferrable> { + let mock_model_names = &request.payload.mock_model_names; + let mut used: Vec = graph + .reachable(&graph.reader_reads) + .into_iter() + .filter(|key| graph.cte(key).is_some_and(|cte| is_mock_name(&cte.name))) + .collect(); + let mut seen_called: Vec<&String> = Vec::new(); + for key in called_mocks { + if seen_called.contains(&key) { + continue; + } + seen_called.push(key); + if graph.cte(key).is_some() && !used.contains(key) { + used.push(key.clone()); + } + } + let mut model_references: OrderedEntries<&[SqlTestAssemblyReference]> = OrderedEntries::new(); + for model in request.models { + model_references.assign(model.name.clone(), model.references.as_slice()); + } + let mut pending: Vec = request + .target_model_names + .iter() + .filter(|name| !mock_model_names.contains(name)) + .cloned() + .collect(); + let mut visited: HashSet = pending.iter().cloned().collect(); + let mut cursor = 0; + while cursor < pending.len() { + let model_name = pending[cursor].clone(); + cursor += 1; + let references: &[SqlTestAssemblyReference] = model_references + .get(&model_name) + .copied() + .unwrap_or_default(); + for reference in references { + let reference = model_reference(reference); + for mock_key in mock_keys(&reference)? { + if graph.cte(&mock_key).is_some() && !used.contains(&mock_key) { + used.push(mock_key); + } + } + if reference.kind == REF_KIND + && !mock_model_names.contains(&reference.name) + && visited.insert(reference.name.clone()) + { + pending.push(reference.name); + } + } + } + Ok(used) +} + +/// Python's `SqlReferenceKind.example_call(*args, quote='"')`. +fn example_call(kind: &str, arguments: &[&str]) -> String { + let quoted: Vec = arguments + .iter() + .map(|argument| format!("\"{}\"", argument.replace('"', "\"\""))) + .collect(); + format!("__{kind}({})", quoted.join(", ")) +} + +/// Python's `_reference_call`. +fn reference_call(reference: &Reference) -> String { + match (&reference.package, reference.kind.as_str()) { + (Some(package), DBT_REF_KIND) => example_call(&reference.kind, &[package, &reference.name]), + _ => example_call(&reference.kind, &[&reference.name]), + } +} + +/// Python's `_mock_reference_help`. +fn mock_reference_help(mock_name: &str, call: &str, reference: &Reference) -> String { + if reference.kind != TABLE_FUNCTION_KIND && reference.package.is_none() { + let mock_cte = format!("__{}__{}", reference.kind, reference.name); + return format!( + "Read a mock by its CTE name instead, for example FROM {mock_cte} rather than \ + FROM {call}, defining {mock_cte} AS (SELECT ...) if the test does not mock it, \ + or write the rows of '{mock_name}' directly." + ); + } + format!("Write the rows of '{mock_name}' directly, for example {mock_name} AS (SELECT ...).") +} + +/// Python's `sql_test_cte_location` and `reference_call_location` on ASCII contents. +fn located(test: &SqlTestAssemblyTest, cte_name: &str, call: &str) -> Span { + let contents = test.contents.as_str(); + let block_offset = contents.find(test.block_sql.as_str()).unwrap_or(0); + let (start, length) = match cte_header(contents, cte_name, block_offset) { + Some((header_start, header_end)) => find_ignoring_case(contents, call, header_end) + .map_or((header_start, cte_name.len()), |found| (found, call.len())), + None => (block_offset, 0), + }; + let (line, column) = line_and_column(contents, start); + let (end_line, end_column) = line_and_column(contents, start + length); + Span { + line, + column, + end_line, + end_column, + } +} + +/// Python's `(? Option<(usize, usize)> { + let bytes = contents.as_bytes(); + let mut start = from; + while let Some(found) = find_ignoring_case(contents, name, start) { + start = found + 1; + if found > 0 && is_word_or_dollar(bytes[found - 1]) { + continue; + } + let mut cursor = found + name.len(); + if matches!(bytes.get(cursor), Some(b'"' | b'`')) { + cursor += 1; + } + let spaces = cursor; + while bytes + .get(cursor) + .is_some_and(|byte| is_python_ascii_space(*byte)) + { + cursor += 1; + } + if cursor == spaces + || !bytes + .get(cursor..cursor + 2) + .is_some_and(|keyword| keyword.eq_ignore_ascii_case(b"AS")) + { + continue; + } + cursor += 2; + while bytes + .get(cursor) + .is_some_and(|byte| is_python_ascii_space(*byte)) + { + cursor += 1; + } + if bytes.get(cursor) == Some(&b'(') { + return Some((found, cursor + 1)); + } + } + None +} + +fn find_ignoring_case(contents: &str, needle: &str, from: usize) -> Option { + let haystack = contents.as_bytes(); + let needle = needle.as_bytes(); + if needle.is_empty() { + return (from <= haystack.len()).then_some(from); + } + (from..=haystack.len().checked_sub(needle.len())?) + .find(|&index| haystack[index..index + needle.len()].eq_ignore_ascii_case(needle)) +} + +fn is_word_or_dollar(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'$') +} + +/// Python's `\s` on ASCII text, which includes the information separators. +fn is_python_ascii_space(byte: u8) -> bool { + matches!( + byte, + b' ' | b'\t' | b'\n' | b'\r' | 0x0b | 0x0c | 0x1c..=0x1f + ) +} + +fn line_and_column(contents: &str, offset: usize) -> (usize, usize) { + let before = &contents.as_bytes()[..offset]; + let line = before.iter().filter(|byte| **byte == b'\n').count() + 1; + let line_start = before + .iter() + .rposition(|byte| *byte == b'\n') + .map_or(0, |index| index + 1); + (line, offset - line_start + 1) +} diff --git a/crates/sqlbuild-analysis/src/compiler/_helpers/sql_tests/mod.rs b/crates/sqlbuild-analysis/src/compiler/_helpers/sql_tests/mod.rs index 364bfe13c4..2661b0ec0a 100644 --- a/crates/sqlbuild-analysis/src/compiler/_helpers/sql_tests/mod.rs +++ b/crates/sqlbuild-analysis/src/compiler/_helpers/sql_tests/mod.rs @@ -1,4 +1,6 @@ +pub mod assembly; pub mod authored_macro_calls; +pub mod case_identity; pub mod cte_rename; pub mod cte_slices; pub mod cte_sql; @@ -7,7 +9,9 @@ pub mod extraction; pub mod glue; pub mod helper_names; pub mod helper_scope; +pub mod macro_call_names; pub mod markers; +pub mod mock_reads; pub mod planning; pub mod reader_references; pub mod reference_calls; diff --git a/crates/sqlbuild-analysis/src/compiler/main/mod.rs b/crates/sqlbuild-analysis/src/compiler/main/mod.rs index 357fadc9d0..94b6925b7e 100644 --- a/crates/sqlbuild-analysis/src/compiler/main/mod.rs +++ b/crates/sqlbuild-analysis/src/compiler/main/mod.rs @@ -1,4 +1,5 @@ pub mod sql_scenario_extraction; +pub mod sql_test_assembly; pub mod sql_test_chain_resolution; pub mod sql_test_chains; pub mod sql_test_difference_sampling; diff --git a/crates/sqlbuild-analysis/src/compiler/main/sql_test_assembly.rs b/crates/sqlbuild-analysis/src/compiler/main/sql_test_assembly.rs new file mode 100644 index 0000000000..f3af180469 --- /dev/null +++ b/crates/sqlbuild-analysis/src/compiler/main/sql_test_assembly.rs @@ -0,0 +1,9 @@ +//! Compiled SQL test facts from a typed request, test by test. + +use crate::compiler::_helpers::sql_tests::assembly; +use crate::compiler::models::{SqlTestAssemblyBatch, SqlTestAssemblyOutcome}; + +/// Assemble every test of `batch` in request order; a deferred test is Python's to assemble. +pub fn assemble_sql_test_batch(batch: &SqlTestAssemblyBatch) -> Vec { + assembly::assemble_batch(batch) +} diff --git a/crates/sqlbuild-analysis/src/compiler/models.rs b/crates/sqlbuild-analysis/src/compiler/models.rs index 03e40d082d..ca6719b481 100644 --- a/crates/sqlbuild-analysis/src/compiler/models.rs +++ b/crates/sqlbuild-analysis/src/compiler/models.rs @@ -182,3 +182,153 @@ pub struct SqlTestAssertionStep { #[serde(default)] pub comparison_body_sql: Option, } + +/// The compiled SQL tests to assemble, with the facts of every model the project compiles. +#[derive(Debug)] +pub struct SqlTestAssemblyBatch { + pub models: Vec, + pub tests: Vec, + pub lexical_syntax: LexicalSyntax, +} + +/// What assembly reads of one model: its name, macro dependencies and references. +#[derive(Debug)] +pub struct SqlTestAssemblyModel { + pub name: String, + pub macro_deps: Vec, + /// Pre-macro SQL Python scans for calls: set only without macro deps and with a macro token. + pub unscanned_macro_source: Option, + pub references: Vec, +} + +/// One logical reference: Python `SqlReferenceKind` value, name and optional package. +#[derive(Clone, Debug)] +pub struct SqlTestAssemblyReference { + pub kind: String, + pub name: String, + pub package: Option, +} + +/// One expanded SQL test case as compile inputs carry it. +#[derive(Debug)] +pub struct SqlTestAssemblyTest { + pub block_name: Option, + pub file_stem: String, + pub relative_path: String, + pub relative_stem: String, + pub contents: String, + pub block_sql: String, + pub block_index: i64, + pub sql_body: String, + pub case_name: Option, + /// `(name, value kind, nullable)` per declared parameter. + pub parameter_schema: Vec<(String, String, bool)>, + pub parameter_values: Vec<(String, SqlTestParameterValue)>, + pub payload: SqlTestAssemblyPayload, +} + +/// A typed SQL value as its kind and payload; decimals keep their unnormalized digits. +#[derive(Clone, Debug)] +pub enum SqlTestParameterValue { + String(String), + Integer(i64), + Boolean(bool), + Float(f64), + Decimal { + negative: bool, + digits: Vec, + exponent: i64, + }, + Null, + List(Vec), + Set(Vec), + Object(Vec<(String, SqlTestParameterValue)>), +} + +/// The part of a test's payload assembly reads. +#[derive(Debug)] +pub enum SqlTestAssemblyPayload { + /// A macro, UDF or table-function test of the named resources. + Direct { + mode: String, + tested_resource_names: Vec, + }, + Model(SqlTestAssemblyModelPayload), +} + +#[derive(Debug)] +pub struct SqlTestAssemblyModelPayload { + pub authored_ctes: Vec, + pub expected_ctes: Vec, + pub assertion_ctes: Vec, + pub expected_model_names: Vec, + pub assertion_target_model_names: Vec, + pub reference_target_model_names: Vec, + pub mock_model_names: Vec, + pub has_macro_mocks: bool, +} + +/// One test's assembled facts, or why Python assembles it. +#[derive(Debug, PartialEq)] +pub enum SqlTestAssemblyOutcome { + Assembled(AssembledSqlTestFacts), + Deferred(SqlTestAssemblyDeferral), +} + +/// The facts Python's `_assemble_compiled_sql_test` computes for one test. +#[derive(Debug, PartialEq)] +pub struct AssembledSqlTestFacts { + pub name: String, + /// `(resource type, name)` per scope dependency, in Python's order. + pub scope_deps: Vec<(&'static str, String)>, + pub target_model_names: Vec, + pub case_fingerprint: Option, + /// Resource name of the test's diagnostics: the block name, else the relative path's stem. + pub diagnostic_resource_name: String, + pub diagnostics: Vec, +} + +/// One P013 diagnostic for a mock that reads a helper calling a reference. +#[derive(Debug, PartialEq, Eq)] +pub struct SqlTestHelperDiagnostic { + pub line: usize, + pub column: usize, + pub end_line: usize, + pub end_column: usize, + pub message: String, + pub help: String, +} + +/// Why one test is assembled by Python instead. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SqlTestAssemblyDeferral { + /// Macro mocks re-expand every model, which only Python's macro expansion does. + MacroMocks, + /// A model's macro call scan raises in Python or meets text Python classifies by Unicode. + MacroCallScan, + /// The reference scan fails or cannot classify the text as Python does. + ReferenceScan, + /// Non-ASCII text where Python's case folding or Unicode classes would apply. + NonAsciiText, + /// A decimal parameter Python's decimal context would round or clamp. + DecimalContext, + /// A parameter value the JSON encoder cannot encode as Python's does. + UnencodableValue, + /// A parsed CTE tree too deep, or otherwise unfit, to read as Python's `to_dict` walk does. + UnreadableTree, +} + +impl SqlTestAssemblyDeferral { + /// The kind recorded for the deferral. + pub fn as_str(self) -> &'static str { + match self { + Self::MacroMocks => "macro_mocks", + Self::MacroCallScan => "macro_call_scan", + Self::ReferenceScan => "reference_scan", + Self::NonAsciiText => "non_ascii_text", + Self::DecimalContext => "decimal_context", + Self::UnencodableValue => "unencodable_value", + Self::UnreadableTree => "unreadable_tree", + } + } +} diff --git a/crates/sqlbuild-analysis/src/compiler/tests/test_sql_test_helper_references.rs b/crates/sqlbuild-analysis/src/compiler/tests/test_sql_test_helper_references.rs index a3b311b682..05d1594a41 100644 --- a/crates/sqlbuild-analysis/src/compiler/tests/test_sql_test_helper_references.rs +++ b/crates/sqlbuild-analysis/src/compiler/tests/test_sql_test_helper_references.rs @@ -1,11 +1,19 @@ use serde_json::{Value, json}; +use crate::compiler::_helpers::sql_tests::extraction::generic_syntax; +use crate::compiler::main::sql_test_assembly::assemble_sql_test_batch; +use crate::compiler::models::{ + AssembledSqlTestFacts, SqlTestAssemblyBatch, SqlTestAssemblyDeferral, SqlTestAssemblyModel, + SqlTestAssemblyModelPayload, SqlTestAssemblyOutcome, SqlTestAssemblyPayload, + SqlTestAssemblyReference, SqlTestAssemblyTest, SqlTestCte, SqlTestHelperDiagnostic, + SqlTestParameterValue, +}; use crate::compiler::tests::helpers::{ chain_helper_reference_case, defined_before, plan_helper_reference_case, plan_helper_reference_response, }; use crate::compiler::tests::test_types::{ - HelperReferenceTestCase, UnresolvedReaderReferenceTestCase, + HelperReferenceTestCase, SqlTestAssemblyTestCase, UnresolvedReaderReferenceTestCase, }; const HELPER_READS_MODEL: (&str, &str) = ( @@ -30,6 +38,14 @@ const ASSERT_DOUBLED: (&str, &str) = ( "SELECT order_id FROM doubled WHERE amount_doubled <> 20", ); +const MOCK_READS_HELPER_TEST: &str = "TEST (name \"mock_reads_helper\");\n\nWITH\n\ +orders_feed AS (SELECT * FROM __source(\"raw_orders\")),\n\ +__ref__stg_orders AS (SELECT * FROM orders_feed),\n\ +__expected__customer_totals AS (SELECT 1 AS customer_id)\nSELECT 1\n"; +/// Python's `build_sql_test_case_fingerprint` of the parameterized case below. +const PYTHON_CASE_FINGERPRINT: &str = + "8f2adcab557ba4476fcb70de1fc11014acda9f1b597a3ca5c3245afb632e5d0f"; + #[test] fn given_helper_cte_references_when_planning_then_references_resolve_in_dependency_order() { let test_cases = [ @@ -491,3 +507,201 @@ fn given_unresolvable_reader_references_when_planning_directly_then_planner_reje ); } } + +#[test] +fn given_compiled_sql_test_inputs_when_assembling_then_facts_match_python_or_defer() { + let cte = |name: &str, sql_body: &str| SqlTestCte { + name: name.to_owned(), + sql_body: sql_body.to_owned(), + }; + let strings = + |names: &[&str]| -> Vec { names.iter().map(|n| (*n).to_owned()).collect() }; + let model_test = |contents: &str, has_macro_mocks: bool| SqlTestAssemblyTest { + block_name: Some("mock_reads_helper".to_owned()), + file_stem: "test_customer_totals".to_owned(), + relative_path: "tests/unit/test_customer_totals.sql".to_owned(), + relative_stem: "test_customer_totals".to_owned(), + contents: contents.to_owned(), + block_sql: contents + .split_once("\n\n") + .map_or("", |(_, body)| body) + .to_owned(), + block_index: 1, + sql_body: "SELECT 1".to_owned(), + case_name: None, + parameter_schema: Vec::new(), + parameter_values: Vec::new(), + payload: SqlTestAssemblyPayload::Model(SqlTestAssemblyModelPayload { + authored_ctes: vec![ + cte("orders_feed", "SELECT * FROM __source(\"raw_orders\")"), + cte("__ref__stg_orders", "SELECT * FROM orders_feed"), + ], + expected_ctes: vec![cte( + "__expected__customer_totals", + "SELECT 1 AS customer_id", + )], + assertion_ctes: Vec::new(), + expected_model_names: strings(&["customer_totals"]), + assertion_target_model_names: Vec::new(), + reference_target_model_names: Vec::new(), + mock_model_names: strings(&["stg_orders"]), + has_macro_mocks, + }), + }; + let model = |name: &str, macro_deps: &[&str], source: Option<&str>, refs: &[(&str, &str)]| { + SqlTestAssemblyModel { + name: name.to_owned(), + macro_deps: strings(macro_deps), + unscanned_macro_source: source.map(str::to_owned), + references: refs + .iter() + .map(|(kind, name)| SqlTestAssemblyReference { + kind: (*kind).to_owned(), + name: (*name).to_owned(), + package: None, + }) + .collect(), + } + }; + let models = || { + vec![ + model("stg_orders", &[], None, &[("source", "raw_orders")]), + model("customer_totals", &[], None, &[("ref", "stg_orders")]), + model( + "order_cents", + &[], + Some("SELECT @enum(\"order_channel\").WEB, @@scale * @ cents(amount) FROM t"), + &[], + ), + model( + "order_dollars", + &[], + Some("SELECT '@cents(x)', @dollars (amount)"), + &[], + ), + ] + }; + let batch = |tests: Vec| SqlTestAssemblyBatch { + models: models(), + tests, + lexical_syntax: generic_syntax(), + }; + let macro_test = SqlTestAssemblyTest { + block_name: None, + payload: SqlTestAssemblyPayload::Direct { + mode: "macro".to_owned(), + tested_resource_names: strings(&["dollars"]), + }, + ..model_test(MOCK_READS_HELPER_TEST, false) + }; + let case_test = |decimal_digits: Vec| SqlTestAssemblyTest { + relative_path: "tests/unit/test_cases.sql".to_owned(), + case_name: Some("first".to_owned()), + parameter_schema: vec![ + ("p_decimal".to_owned(), "decimal".to_owned(), true), + ("p_float".to_owned(), "float".to_owned(), false), + ], + parameter_values: vec![ + ( + "p_decimal".to_owned(), + SqlTestParameterValue::Decimal { + negative: true, + digits: decimal_digits, + exponent: -4, + }, + ), + ("p_float".to_owned(), SqlTestParameterValue::Float(0.1)), + ( + "p_string".to_owned(), + SqlTestParameterValue::String("caf\u{e9}".to_owned()), + ), + ], + payload: SqlTestAssemblyPayload::Model(SqlTestAssemblyModelPayload { + authored_ctes: Vec::new(), + expected_ctes: Vec::new(), + assertion_ctes: Vec::new(), + expected_model_names: strings(&["orders", "customers"]), + assertion_target_model_names: strings(&["orders"]), + reference_target_model_names: Vec::new(), + mock_model_names: Vec::new(), + has_macro_mocks: false, + }), + ..model_test(MOCK_READS_HELPER_TEST, false) + }; + let test_cases = [ + SqlTestAssemblyTestCase { + description: "a mock reading a helper that calls a source reports P013 at the call", + batch: batch(vec![model_test(MOCK_READS_HELPER_TEST, false)]), + expected_outcomes: vec![SqlTestAssemblyOutcome::Assembled(AssembledSqlTestFacts { + name: "mock_reads_helper".to_owned(), + scope_deps: vec![("model", "customer_totals".to_owned())], + target_model_names: strings(&["customer_totals"]), + case_fingerprint: None, + diagnostic_resource_name: "mock_reads_helper".to_owned(), + diagnostics: vec![SqlTestHelperDiagnostic { + line: 4, + column: 31, + end_line: 4, + end_column: 53, + message: "SQL test mock '__ref__stg_orders' reads helper CTE 'orders_feed', \ + which calls __source(\"raw_orders\"); mocks and fixtures are \ + defined before the models the test runs, so the helper cannot \ + be resolved for them" + .to_owned(), + help: "Read a mock by its CTE name instead, for example FROM \ + __source__raw_orders rather than FROM __source(\"raw_orders\"), \ + defining __source__raw_orders AS (SELECT ...) if the test does not \ + mock it, or write the rows of '__ref__stg_orders' directly." + .to_owned(), + }], + })], + }, + SqlTestAssemblyTestCase { + description: "macro mocks, non-ASCII text and out-of-context decimals defer", + batch: batch(vec![ + model_test(MOCK_READS_HELPER_TEST, true), + model_test(&format!("-- caf\u{e9}\n{MOCK_READS_HELPER_TEST}"), false), + case_test(vec![1; 29]), + ]), + expected_outcomes: vec![ + SqlTestAssemblyOutcome::Deferred(SqlTestAssemblyDeferral::MacroMocks), + SqlTestAssemblyOutcome::Deferred(SqlTestAssemblyDeferral::NonAsciiText), + SqlTestAssemblyOutcome::Deferred(SqlTestAssemblyDeferral::DecimalContext), + ], + }, + SqlTestAssemblyTestCase { + description: "a case fingerprint and a macro test's scanned scope match Python", + batch: batch(vec![case_test(vec![2, 4, 7, 0, 0]), macro_test]), + expected_outcomes: vec![ + SqlTestAssemblyOutcome::Assembled(AssembledSqlTestFacts { + name: "mock_reads_helper [first]".to_owned(), + scope_deps: vec![ + ("model", "orders".to_owned()), + ("model", "customers".to_owned()), + ], + target_model_names: strings(&["orders", "customers"]), + case_fingerprint: Some(PYTHON_CASE_FINGERPRINT.to_owned()), + diagnostic_resource_name: "mock_reads_helper".to_owned(), + diagnostics: Vec::new(), + }), + SqlTestAssemblyOutcome::Assembled(AssembledSqlTestFacts { + name: "test_customer_totals".to_owned(), + scope_deps: vec![("model", "order_dollars".to_owned())], + target_model_names: Vec::new(), + case_fingerprint: None, + diagnostic_resource_name: "test_customer_totals".to_owned(), + diagnostics: Vec::new(), + }), + ], + }, + ]; + + for test_case in test_cases { + assert_eq!( + assemble_sql_test_batch(&test_case.batch), + test_case.expected_outcomes, + "{}", + test_case.description + ); + } +} diff --git a/crates/sqlbuild-analysis/src/compiler/tests/test_types.rs b/crates/sqlbuild-analysis/src/compiler/tests/test_types.rs index c109a747fb..3d59ff16d4 100644 --- a/crates/sqlbuild-analysis/src/compiler/tests/test_types.rs +++ b/crates/sqlbuild-analysis/src/compiler/tests/test_types.rs @@ -186,3 +186,9 @@ pub(crate) struct GlueErrorProjectionTestCase { pub(crate) warnings: &'static [(&'static str, &'static str)], pub(crate) expected_messages: &'static [&'static str], } + +pub(crate) struct SqlTestAssemblyTestCase { + pub(crate) description: &'static str, + pub(crate) batch: crate::compiler::models::SqlTestAssemblyBatch, + pub(crate) expected_outcomes: Vec, +} diff --git a/crates/sqlbuild-python/src/bindings/_helpers/sql_test_glue/assembly.rs b/crates/sqlbuild-python/src/bindings/_helpers/sql_test_glue/assembly.rs new file mode 100644 index 0000000000..8769cb3b70 --- /dev/null +++ b/crates/sqlbuild-python/src/bindings/_helpers/sql_test_glue/assembly.rs @@ -0,0 +1,253 @@ +//! Assemble compiled SQL test facts from compile inputs' own objects, without a JSON request. + +use pyo3::exceptions::PyValueError; +use pyo3::prelude::{Bound, PyAny, PyAnyMethods, PyModule, PyModuleMethods, PyResult, Python}; +use pyo3::{FromPyObject, PyErr, pyfunction, wrap_pyfunction}; +use sqlbuild_analysis::compiler::main::sql_test_assembly::assemble_sql_test_batch; +use sqlbuild_analysis::compiler::models::{ + AssembledSqlTestFacts, SqlTestAssemblyBatch, SqlTestAssemblyModel, SqlTestAssemblyModelPayload, + SqlTestAssemblyOutcome, SqlTestAssemblyPayload, SqlTestAssemblyReference, SqlTestAssemblyTest, + SqlTestCte, SqlTestParameterValue, +}; + +use crate::bindings::_helpers::boundary::panics::value_error; +use crate::bindings::_helpers::sqltext::lexical_syntax::LexicalSyntaxInput; +use crate::bindings::types::CompilerDetach; + +const MACRO_TOKEN: char = '@'; + +/// `(line, column, end line, end column, message, help)` of one P013 diagnostic. +type DiagnosticRow = (usize, usize, usize, usize, String, String); +/// `(name, scope deps, target models, case fingerprint, diagnostic resource, diagnostics)`. +type FactsRow = ( + String, + Vec<(&'static str, String)>, + Vec, + Option, + String, + Vec, +); +/// The test's facts, or the deferral kind of a test Python assembles. +type AssemblyRow = (Option, Option<&'static str>); + +/// The Python assembly request: the project's model inputs and the SQL test inputs. +#[derive(FromPyObject)] +struct AssemblyRequest<'py> { + #[pyo3(attribute)] + model_inputs: Vec>, + #[pyo3(attribute)] + test_inputs: Vec>, + #[pyo3(attribute)] + lexical_syntax: LexicalSyntaxInput, +} + +/// Each test input's assembled facts, or the deferral of a test Python must assemble. +#[pyfunction] +fn assemble_compiled_sql_tests( + py: Python<'_>, + request: AssemblyRequest<'_>, +) -> PyResult> { + let batch = SqlTestAssemblyBatch { + models: request + .model_inputs + .iter() + .map(assembly_model) + .collect::>()?, + tests: request + .test_inputs + .iter() + .map(assembly_test) + .collect::>()?, + lexical_syntax: request.lexical_syntax.into(), + }; + let outcomes = py + .compiler_detach(|| Ok(assemble_sql_test_batch(&batch))) + .map_err(value_error)?; + Ok(outcomes.into_iter().map(assembly_row).collect()) +} + +fn assembly_model(model: &Bound<'_, PyAny>) -> PyResult { + let macro_deps: Vec = model.getattr("macro_deps")?.extract()?; + let unscanned_macro_source = if macro_deps.is_empty() { + Some(model.getattr("macro_source_sql")?.extract::()?) + .filter(|sql| sql.contains(MACRO_TOKEN)) + } else { + None + }; + Ok(SqlTestAssemblyModel { + name: model + .getattr("model_file")? + .getattr("file_path")? + .getattr("stem")? + .extract()?, + macro_deps, + unscanned_macro_source, + references: model + .getattr("references")? + .try_iter()? + .map(|reference| { + let reference = reference?; + Ok(SqlTestAssemblyReference { + kind: reference.getattr("ref_kind")?.extract()?, + name: reference.getattr("ref_name")?.extract()?, + package: reference.getattr("ref_package")?.extract()?, + }) + }) + .collect::>()?, + }) +} + +fn assembly_test(test: &Bound<'_, PyAny>) -> PyResult { + let test_file = test.getattr("test_file")?; + let test_block = test.getattr("test_block")?; + let relative_path = test_file.getattr("relative_path")?; + let payload = test.getattr("payload")?; + let payload = if payload.hasattr("actual_cte")? { + SqlTestAssemblyPayload::Direct { + mode: payload.getattr("mode")?.extract()?, + tested_resource_names: payload.getattr("tested_resource_names")?.extract()?, + } + } else { + SqlTestAssemblyPayload::Model(SqlTestAssemblyModelPayload { + authored_ctes: ctes(&payload, "authored_ctes")?, + expected_ctes: ctes(&payload, "expected_ctes")?, + assertion_ctes: ctes(&payload, "assertion_ctes")?, + expected_model_names: payload.getattr("expected_model_names")?.extract()?, + assertion_target_model_names: payload + .getattr("assertion_target_model_names")? + .extract()?, + reference_target_model_names: payload + .getattr("reference_target_model_names")? + .extract()?, + mock_model_names: payload.getattr("mock_model_names")?.extract()?, + has_macro_mocks: payload.getattr("macro_mocks")?.is_truthy()?, + }) + }; + Ok(SqlTestAssemblyTest { + block_name: test_block.getattr("name")?.extract()?, + file_stem: test_file.getattr("file_path")?.getattr("stem")?.extract()?, + relative_path: relative_path.call_method0("as_posix")?.extract()?, + relative_stem: relative_path.getattr("stem")?.extract()?, + contents: test_file.getattr("contents")?.extract()?, + block_sql: test_block.getattr("sql_body")?.extract()?, + block_index: test_block.getattr("test_index")?.extract()?, + sql_body: test.getattr("sql_body")?.extract()?, + case_name: test.getattr("case_name")?.extract()?, + parameter_schema: test + .getattr("parameter_schema")? + .try_iter()? + .map(|parameter| { + let parameter = parameter?; + Ok(( + parameter.getattr("name")?.extract()?, + parameter.getattr("value_type")?.extract()?, + parameter.getattr("nullable")?.extract()?, + )) + }) + .collect::>()?, + parameter_values: test + .getattr("parameter_values")? + .try_iter()? + .map(|entry| { + let (name, value): (String, Bound<'_, PyAny>) = entry?.extract()?; + Ok((name, parameter_value(&value)?)) + }) + .collect::>()?, + payload, + }) +} + +fn ctes(owner: &Bound<'_, PyAny>, attribute: &str) -> PyResult> { + owner + .getattr(attribute)? + .try_iter()? + .map(|value| { + let value = value?; + Ok(SqlTestCte { + name: value.getattr("name")?.extract()?, + sql_body: value.getattr("sql_body")?.extract()?, + }) + }) + .collect() +} + +/// One `SqlValue`, read by its logical kind; decimals keep `as_tuple()`'s digits. +fn parameter_value(value: &Bound<'_, PyAny>) -> PyResult { + let kind: String = value.getattr("logical_type")?.getattr("kind")?.extract()?; + let payload = value.getattr("value")?; + Ok(match kind.as_str() { + "string" => SqlTestParameterValue::String(payload.extract()?), + "integer" => SqlTestParameterValue::Integer(payload.extract()?), + "boolean" => SqlTestParameterValue::Boolean(payload.extract()?), + "float" => SqlTestParameterValue::Float(payload.extract()?), + "decimal" => { + let (sign, digits, exponent): (u8, Vec, i64) = + payload.call_method0("as_tuple")?.extract()?; + SqlTestParameterValue::Decimal { + negative: sign == 1, + digits, + exponent, + } + } + "null" => SqlTestParameterValue::Null, + "list" => SqlTestParameterValue::List(parameter_values(&payload)?), + "set" => SqlTestParameterValue::Set(parameter_values(&payload)?), + "object" => SqlTestParameterValue::Object( + payload + .try_iter()? + .map(|entry| { + let (key, item): (String, Bound<'_, PyAny>) = entry?.extract()?; + Ok((key, parameter_value(&item)?)) + }) + .collect::>()?, + ), + other => { + return Err(PyErr::new::(format!( + "unknown SQL value kind {other:?}" + ))); + } + }) +} + +fn parameter_values(items: &Bound<'_, PyAny>) -> PyResult> { + items + .try_iter()? + .map(|item| parameter_value(&item?)) + .collect() +} + +fn assembly_row(outcome: SqlTestAssemblyOutcome) -> AssemblyRow { + match outcome { + SqlTestAssemblyOutcome::Assembled(facts) => (Some(facts_row(facts)), None), + SqlTestAssemblyOutcome::Deferred(deferral) => (None, Some(deferral.as_str())), + } +} + +fn facts_row(facts: AssembledSqlTestFacts) -> FactsRow { + ( + facts.name, + facts.scope_deps, + facts.target_model_names, + facts.case_fingerprint, + facts.diagnostic_resource_name, + facts + .diagnostics + .into_iter() + .map(|diagnostic| { + ( + diagnostic.line, + diagnostic.column, + diagnostic.end_line, + diagnostic.end_column, + diagnostic.message, + diagnostic.help, + ) + }) + .collect(), + ) +} + +pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { + module.add_function(wrap_pyfunction!(assemble_compiled_sql_tests, module)?)?; + Ok(()) +} diff --git a/crates/sqlbuild-python/src/bindings/_helpers/sql_test_glue/mod.rs b/crates/sqlbuild-python/src/bindings/_helpers/sql_test_glue/mod.rs index 8be67c2871..75d847c142 100644 --- a/crates/sqlbuild-python/src/bindings/_helpers/sql_test_glue/mod.rs +++ b/crates/sqlbuild-python/src/bindings/_helpers/sql_test_glue/mod.rs @@ -1,2 +1,3 @@ +pub(crate) mod assembly; pub(crate) mod planning; pub(crate) mod registration; diff --git a/crates/sqlbuild-python/src/bindings/_helpers/sql_test_glue/registration.rs b/crates/sqlbuild-python/src/bindings/_helpers/sql_test_glue/registration.rs index d7cf7c6c23..ac702ccbfd 100644 --- a/crates/sqlbuild-python/src/bindings/_helpers/sql_test_glue/registration.rs +++ b/crates/sqlbuild-python/src/bindings/_helpers/sql_test_glue/registration.rs @@ -1,9 +1,10 @@ -//! Register the native SQL test planning functions. +//! Register the native SQL test planning and assembly functions. use pyo3::prelude::{Bound, PyModule, PyResult}; -use crate::bindings::_helpers::sql_test_glue::planning; +use crate::bindings::_helpers::sql_test_glue::{assembly, planning}; pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { - planning::register(module) + planning::register(module)?; + assembly::register(module) } diff --git a/scripts/compiler_differential/_helpers/analysis_corpus/sql_test_glue.py b/scripts/compiler_differential/_helpers/analysis_corpus/sql_test_glue.py index 04d7b4b944..cc00d6d889 100644 --- a/scripts/compiler_differential/_helpers/analysis_corpus/sql_test_glue.py +++ b/scripts/compiler_differential/_helpers/analysis_corpus/sql_test_glue.py @@ -1,4 +1,4 @@ -"""Failure cases owned by the native SQL-test planning glue lane.""" +"""Failure cases owned by the native SQL-test planning and assembly glue lane.""" from scripts.compiler_differential._helpers.corpus.case_builder import failure_case from scripts.compiler_differential.models import FailureCase @@ -75,4 +75,23 @@ def sql_test_glue_failure_cases() -> tuple[FailureCase, ...]: ), ), ), + failure_case( + name="sql-test-glue-mock-reads-referencing-helper", + expected_code="P013", + expected_message=( + "SQL test mock '__ref__stg_orders' reads helper CTE 'orders_feed', which calls " + '__source("raw_orders"); mocks and fixtures are defined before the models the ' + "test runs, so the helper cannot be resolved for them" + ), + expected_location=(4, 31), + files=_test( + header='name "mock_reads_helper"', + ctes=( + 'orders_feed AS (SELECT * FROM __source("raw_orders")),\n' + "__ref__stg_orders AS (SELECT * FROM orders_feed),\n" + "__expected__customer_totals AS (\n" + " SELECT 10 AS customer_id, 5.0 AS total_amount\n)" + ), + ), + ), ) diff --git a/src/sqlbuild/_native.pyi b/src/sqlbuild/_native.pyi index 5d194a0bf1..7f9dcc47de 100644 --- a/src/sqlbuild/_native.pyi +++ b/src/sqlbuild/_native.pyi @@ -4,10 +4,11 @@ from collections.abc import Sequence from typing import Any, TypedDict from sqlbuild.compiler.sql_test_glue.models import ( + NativeSqlTestAssemblyRequest, NativeSqlTestChainRequest, NativeSqlTestPlanningRequest, ) -from sqlbuild.compiler.sql_test_glue.types import NativeSqlTestPlanRow +from sqlbuild.compiler.sql_test_glue.types import NativeSqlTestAssemblyRow, NativeSqlTestPlanRow BUILD_IDENTITY: str @@ -620,6 +621,9 @@ def plan_compiled_sql_tests( request: NativeSqlTestPlanningRequest, / ) -> tuple[list[NativeSqlTestPlanRow], int, int]: ... def resolve_compiled_sql_test_chains(request: NativeSqlTestChainRequest, /) -> list[list[str]]: ... +def assemble_compiled_sql_tests( + request: NativeSqlTestAssemblyRequest, / +) -> list[NativeSqlTestAssemblyRow]: ... # Native analysis: compiled project assembly. def assemble_project_resource_facts( diff --git a/src/sqlbuild/compiler/compile/_helpers/native_stages/sql_tests.py b/src/sqlbuild/compiler/compile/_helpers/native_stages/sql_tests.py index fde4b1cf87..6704d1b09f 100644 --- a/src/sqlbuild/compiler/compile/_helpers/native_stages/sql_tests.py +++ b/src/sqlbuild/compiler/compile/_helpers/native_stages/sql_tests.py @@ -4,6 +4,7 @@ from collections.abc import Callable +from sqlbuild.compiler.compile._helpers.diagnostics.collector import report_compile_diagnostic from sqlbuild.compiler.compile.models import ( CompiledSqlTest, CompileProjectInputs, @@ -14,6 +15,7 @@ from sqlbuild.compiler.sql_test_glue.main._assemble_native_sql_tests import ( assemble_native_sql_tests, ) +from sqlbuild.compiler.sql_test_glue.models import NativeSqlTestAssembly def assemble_sql_tests_by_engine( @@ -23,8 +25,15 @@ def assemble_sql_tests_by_engine( ) -> tuple[CompiledSqlTest, ...]: """Assemble every SQL test natively, or each test in Python where native defers.""" - if native_stage_enabled(NativeStage.SQL_TEST_GLUE): - native_tests: tuple[CompiledSqlTest, ...] | None = assemble_native_sql_tests(inputs=inputs) - if native_tests is not None: - return native_tests - return tuple(assemble_python_test(test_input) for test_input in inputs.test_inputs) + if not native_stage_enabled(NativeStage.SQL_TEST_GLUE): + return tuple(assemble_python_test(test_input) for test_input in inputs.test_inputs) + native: tuple[NativeSqlTestAssembly | None, ...] = assemble_native_sql_tests(inputs=inputs) + tests: list[CompiledSqlTest] = [] + for test_input, assembly in zip(inputs.test_inputs, native, strict=True): + if assembly is None: + tests.append(assemble_python_test(test_input)) + continue + for key, diagnostic in assembly.diagnostics: + report_compile_diagnostic(key=key, diagnostic=diagnostic) + tests.append(assembly.test) + return tuple(tests) diff --git a/src/sqlbuild/compiler/sql_test_glue/_helpers/__init__.py b/src/sqlbuild/compiler/sql_test_glue/_helpers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/sqlbuild/compiler/sql_test_glue/_helpers/assembly.py b/src/sqlbuild/compiler/sql_test_glue/_helpers/assembly.py new file mode 100644 index 0000000000..cb452078f9 --- /dev/null +++ b/src/sqlbuild/compiler/sql_test_glue/_helpers/assembly.py @@ -0,0 +1,155 @@ +"""Compiled SQL tests built from the facts the native assembly returns.""" + +from __future__ import annotations + +from pathlib import Path + +from sqlbuild.compiler.compile.constants import SQL_TEST_HELPER_REFERENCE_CODE +from sqlbuild.compiler.compile.models import ( + CompiledDirectLogicSqlTestPayload, + CompileDirectLogicSqlTestInputPayload, + CompiledModelSqlTestPayload, + CompiledObjectKey, + CompiledSqlTest, + CompiledSqlTestResource, + CompileModelSqlTestInputPayload, + CompilerDiagnostic, + CompileSqlTestInput, +) +from sqlbuild.compiler.compile.types import ( + CompiledResourceType, + DiagnosticPhase, + DiagnosticSeverity, +) +from sqlbuild.compiler.sql_test_glue._helpers.deferrals import record_sql_test_assembly_deferral +from sqlbuild.compiler.sql_test_glue.models import NativeSqlTestAssembly +from sqlbuild.compiler.sql_test_glue.types import ( + NativeSqlTestAssemblyRow, + NativeSqlTestDiagnosticRow, + NativeSqlTestFactsRow, +) +from sqlbuild.spec.contracts.models import SourceLocation + + +def native_sql_test_assemblies( + *, test_inputs: tuple[CompileSqlTestInput, ...], rows: list[NativeSqlTestAssemblyRow] +) -> tuple[NativeSqlTestAssembly | None, ...]: + """One assembly per test input, or None, recorded, where the native assembly defers.""" + + assemblies: list[NativeSqlTestAssembly | None] = [] + for test_input, (facts, deferral) in zip(test_inputs, rows, strict=True): + if facts is None: + record_sql_test_assembly_deferral(kind=str(deferral)) + assemblies.append(None) + continue + assemblies.append(native_sql_test_assembly(test_input=test_input, facts=facts)) + return tuple(assemblies) + + +def native_sql_test_assembly( + *, test_input: CompileSqlTestInput, facts: NativeSqlTestFactsRow +) -> NativeSqlTestAssembly: + """The compiled test `_assemble_compiled_sql_test` builds, from natively assembled facts.""" + + name, scope_dep_rows, target_model_names, case_fingerprint, resource_name, diagnostics = facts + scope_deps: tuple[CompiledObjectKey, ...] = tuple( + CompiledObjectKey(resource_type=resource_type, name=dep_name) + for resource_type, dep_name in scope_dep_rows + ) + payload: CompileModelSqlTestInputPayload | CompileDirectLogicSqlTestInputPayload = ( + test_input.payload + ) + compiled_payload: CompiledModelSqlTestPayload | CompiledDirectLogicSqlTestPayload + tested_resources: tuple[CompiledSqlTestResource, ...] = () + expected_model_names: tuple[str, ...] = () + assertion_names: tuple[str, ...] = () + assertion_target_model_names: tuple[str, ...] = () + read_helper_names: tuple[str, ...] = () + reference_target_model_names: tuple[str, ...] = () + if isinstance(payload, CompileDirectLogicSqlTestInputPayload): + compiled_payload = CompiledDirectLogicSqlTestPayload( + mode=payload.mode, + helper_ctes=payload.helper_ctes, + actual_cte=payload.actual_cte, + expected_cte=payload.expected_cte, + tested_resource_names=payload.tested_resource_names, + ) + tested_resources = tuple( + CompiledSqlTestResource(kind=payload.mode, name=resource_name) + for resource_name in payload.tested_resource_names + ) + else: + compiled_payload = CompiledModelSqlTestPayload( + authored_ctes=payload.authored_ctes, + macro_mocks=payload.macro_mocks, + model_query_overrides={}, + mock_model_names=payload.mock_model_names, + mock_source_names=payload.mock_source_names, + mock_seed_names=payload.mock_seed_names, + mock_dbt_ref_names=payload.mock_dbt_ref_names, + mock_table_function_names=payload.mock_table_function_names, + expected_ctes=payload.expected_ctes, + expected_model_names=payload.expected_model_names, + assertion_ctes=payload.assertion_ctes, + assertion_names=payload.assertion_names, + ) + expected_model_names = payload.expected_model_names + assertion_names = payload.assertion_names + assertion_target_model_names = payload.assertion_target_model_names + read_helper_names = payload.read_helper_names + reference_target_model_names = payload.reference_target_model_names + return NativeSqlTestAssembly( + test=CompiledSqlTest( + key=CompiledObjectKey(resource_type=CompiledResourceType.SQL_TEST, name=name), + scope_deps=scope_deps, + name=name, + test_file=test_input.test_file, + test_block=test_input.test_block, + sql_body=test_input.sql_body, + mode=test_input.mode, + payload=compiled_payload, + source_path=test_input.test_file.relative_path, + ownership_root=test_input.test_file.ownership_root, + block_index=test_input.test_block.test_index, + explicit_name=test_input.test_block.name, + parent_name=test_input.parent_name, + case_name=test_input.case_name, + case_index=test_input.case_index, + case_fingerprint=case_fingerprint, + parameter_schema=test_input.parameter_schema, + parameter_values=test_input.parameter_values, + expected_model_names=expected_model_names, + assertion_names=assertion_names, + assertion_target_model_names=assertion_target_model_names, + read_helper_names=read_helper_names, + reference_target_model_names=reference_target_model_names, + target_model_names=tuple(target_model_names), + tested_resources=tested_resources, + ), + diagnostics=tuple( + _keyed_diagnostic(test_input=test_input, resource_name=resource_name, row=row) + for row in diagnostics + ), + ) + + +def _keyed_diagnostic( + *, test_input: CompileSqlTestInput, resource_name: str, row: NativeSqlTestDiagnosticRow +) -> tuple[tuple[str, ...], CompilerDiagnostic]: + line, column, end_line, end_column, message, help_text = row + path: Path = test_input.test_file.relative_path + return ( + (SQL_TEST_HELPER_REFERENCE_CODE, path.as_posix(), f"{line:09d}:{column:09d}", message), + CompilerDiagnostic( + phase=DiagnosticPhase.COMPILE, + severity=DiagnosticSeverity.ERROR, + code=SQL_TEST_HELPER_REFERENCE_CODE, + message=message, + resource_type=CompiledResourceType.SQL_TEST, + resource_name=resource_name, + location=SourceLocation( + path=path, line=line, column=column, end_line=end_line, end_column=end_column + ), + help=help_text, + ), + ) diff --git a/src/sqlbuild/compiler/sql_test_glue/_helpers/deferrals.py b/src/sqlbuild/compiler/sql_test_glue/_helpers/deferrals.py new file mode 100644 index 0000000000..bf25fb8907 --- /dev/null +++ b/src/sqlbuild/compiler/sql_test_glue/_helpers/deferrals.py @@ -0,0 +1,24 @@ +"""Debug-only record of SQL tests the native assembly hands back to Python.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +from sqlbuild.compiler.lineage.constants import ANALYSIS_DEFERRAL_RECORD_PREFIX +from sqlbuild.compiler.sql_analysis.constants import ANALYSIS_RECORD_DIR_ENV_VAR +from sqlbuild.compiler.sql_test_glue.constants import SQL_TEST_ASSEMBLY_DEFERRAL_SITE + + +def record_sql_test_assembly_deferral(*, kind: str) -> None: + """Append one `{"kind", "site"}` line when the analysis record directory is set.""" + + directory: str | None = os.environ.get(ANALYSIS_RECORD_DIR_ENV_VAR) + if not directory: + return + target: Path = Path(directory) + target.mkdir(parents=True, exist_ok=True) + record: str = json.dumps({"kind": kind, "site": SQL_TEST_ASSEMBLY_DEFERRAL_SITE}) + with open(target / f"{ANALYSIS_DEFERRAL_RECORD_PREFIX}{os.getpid()}.jsonl", "a") as handle: + _ = handle.write(record + "\n") diff --git a/src/sqlbuild/compiler/sql_test_glue/constants.py b/src/sqlbuild/compiler/sql_test_glue/constants.py index 727ece2d99..8c14439660 100644 --- a/src/sqlbuild/compiler/sql_test_glue/constants.py +++ b/src/sqlbuild/compiler/sql_test_glue/constants.py @@ -3,3 +3,4 @@ from __future__ import annotations CURSOR_INTRINSIC_NAMES: tuple[str, ...] = ("__cursor_start", "__cursor_end") +SQL_TEST_ASSEMBLY_DEFERRAL_SITE: str = "sql_test_assembly" diff --git a/src/sqlbuild/compiler/sql_test_glue/main/_assemble_native_sql_tests.py b/src/sqlbuild/compiler/sql_test_glue/main/_assemble_native_sql_tests.py index b732f71039..30b1e4dd08 100644 --- a/src/sqlbuild/compiler/sql_test_glue/main/_assemble_native_sql_tests.py +++ b/src/sqlbuild/compiler/sql_test_glue/main/_assemble_native_sql_tests.py @@ -2,12 +2,26 @@ from __future__ import annotations -from sqlbuild.compiler.compile.models import CompiledSqlTest, CompileProjectInputs +import sqlbuild._native as native_module +from sqlbuild.compiler.compile.models import CompileProjectInputs +from sqlbuild.compiler.sql_test_glue._helpers.assembly import native_sql_test_assemblies +from sqlbuild.compiler.sql_test_glue.models import ( + NativeSqlTestAssembly, + NativeSqlTestAssemblyRequest, +) +from sqlbuild.compiler.sql_test_glue.types import NativeSqlTestAssemblyRow def assemble_native_sql_tests( *, inputs: CompileProjectInputs -) -> tuple[CompiledSqlTest, ...] | None: - """Return one compiled test per test input, or None where Python must assemble the tests.""" +) -> tuple[NativeSqlTestAssembly | None, ...]: + """Return one assembly per test input, or None for each test Python must assemble.""" - return None + rows: list[NativeSqlTestAssemblyRow] = native_module.assemble_compiled_sql_tests( + NativeSqlTestAssemblyRequest( + model_inputs=inputs.model_inputs, + test_inputs=inputs.test_inputs, + lexical_syntax=inputs.sql_lexical_syntax.native_mapping, + ) + ) + return native_sql_test_assemblies(test_inputs=inputs.test_inputs, rows=rows) diff --git a/src/sqlbuild/compiler/sql_test_glue/models.py b/src/sqlbuild/compiler/sql_test_glue/models.py index 0883787198..69403cb491 100644 --- a/src/sqlbuild/compiler/sql_test_glue/models.py +++ b/src/sqlbuild/compiler/sql_test_glue/models.py @@ -1,10 +1,16 @@ -"""Requests the native SQL-test planning glue reads from compiled objects.""" +"""Requests and results of the native SQL-test glue, read from compiled objects.""" from __future__ import annotations from dataclasses import dataclass -from sqlbuild.compiler.compile.models import CompiledModel, CompiledSqlTest +from sqlbuild.compiler.compile.models import ( + CompiledModel, + CompiledSqlTest, + CompileModelInput, + CompilerDiagnostic, + CompileSqlTestInput, +) @dataclass(frozen=True) @@ -32,3 +38,20 @@ class NativeSqlTestChainRequest: models: tuple[CompiledModel, ...] tests: tuple[CompiledSqlTest, ...] lexical_syntax: dict[str, object] + + +@dataclass(frozen=True) +class NativeSqlTestAssemblyRequest: + """The project's model inputs and the SQL test inputs whose compiled facts to assemble.""" + + model_inputs: tuple[CompileModelInput, ...] + test_inputs: tuple[CompileSqlTestInput, ...] + lexical_syntax: dict[str, object] + + +@dataclass(frozen=True) +class NativeSqlTestAssembly: + """One natively assembled test and the keyed diagnostics to report before it.""" + + test: CompiledSqlTest + diagnostics: tuple[tuple[tuple[str, ...], CompilerDiagnostic], ...] diff --git a/src/sqlbuild/compiler/sql_test_glue/types.py b/src/sqlbuild/compiler/sql_test_glue/types.py index 9a3fdf65c4..3e995450c1 100644 --- a/src/sqlbuild/compiler/sql_test_glue/types.py +++ b/src/sqlbuild/compiler/sql_test_glue/types.py @@ -1,4 +1,4 @@ -"""Row shapes the native SQL-test planning glue returns.""" +"""Row shapes the native SQL-test glue returns.""" from __future__ import annotations @@ -22,3 +22,13 @@ list[NativePlanWarningRow], list[str], ] +type NativeSqlTestDiagnosticRow = tuple[int, int, int, int, str, str] +type NativeSqlTestFactsRow = tuple[ + str, + list[tuple[str, str]], + list[str], + str | None, + str, + list[NativeSqlTestDiagnosticRow], +] +type NativeSqlTestAssemblyRow = tuple[NativeSqlTestFactsRow | None, str | None] diff --git a/tests/e2e/src/sqlbuild/cli/commands/main/compile/test_native_analysis_seams.py b/tests/e2e/src/sqlbuild/cli/commands/main/compile/test_native_analysis_seams.py index bd27e5c2d5..b2d96bc1b3 100644 --- a/tests/e2e/src/sqlbuild/cli/commands/main/compile/test_native_analysis_seams.py +++ b/tests/e2e/src/sqlbuild/cli/commands/main/compile/test_native_analysis_seams.py @@ -13,6 +13,7 @@ from sqlbuild.compiler.lineage.models import ProjectColumnLineage from sqlbuild.compiler.planner.models import NativeSqlTestArtifact from sqlbuild.compiler.project_assembly.models import NativeProjectResources +from sqlbuild.compiler.sql_test_glue.models import NativeSqlTestAssembly from tests.e2e.src.sqlbuild.cli.commands.main.compile._test_types import ( NativeAnalysisSeamTestCase, ) @@ -36,7 +37,7 @@ "assemble_native_project_resources": [ANY], "infer_native_expression_source_shapes": [ANY], "analyze_native_model_sql": [ANY], - "assemble_native_sql_tests": [None], + "assemble_native_sql_tests": [ANY], "complete_native_semantic_diagnostics": [ANY], "evaluate_native_model_contracts": [ANY], "native_promotion_conflict_diagnostics": [()], @@ -83,6 +84,10 @@ def test_given_project_when_compiling_with_each_engine_then_native_seams_answer_ assert isinstance(preview_seams["assemble_native_project_resources"][0], NativeProjectResources) assert isinstance(preview_seams["analyze_native_model_sql"][0], dict) assert isinstance(preview_seams["infer_native_expression_source_shapes"][0], tuple) + assembled_tests: object = preview_seams["assemble_native_sql_tests"][0] + assert isinstance(assembled_tests, tuple) + assert assembled_tests + assert all(isinstance(assembled, NativeSqlTestAssembly) for assembled in assembled_tests) planned_artifacts: object = preview_seams["plan_native_sql_test_artifacts"][0] assert isinstance(planned_artifacts, tuple) assert planned_artifacts diff --git a/tests/integration/src/sqlbuild/compiler/sql_test_glue/_test_types.py b/tests/integration/src/sqlbuild/compiler/sql_test_glue/_test_types.py index 1ead44a814..f4abf5a7a3 100644 --- a/tests/integration/src/sqlbuild/compiler/sql_test_glue/_test_types.py +++ b/tests/integration/src/sqlbuild/compiler/sql_test_glue/_test_types.py @@ -22,3 +22,15 @@ class GeneratedSqlTestPlanningParityTestCase: expected_native_raised_kinds: frozenset[str] expected_minimum_answered_batches: int expected_minimum_raised_outcomes: int + + +@dataclass(frozen=True) +class GeneratedSqlTestAssemblyParityTestCase: + """Seeded SQL-test projects native and Python assembly must compile identically.""" + + description: str + seed: int + count: int + test_count: int + shape: SqlTestCorpusShape + expected_native_assemblies: dict[str, int] diff --git a/tests/integration/src/sqlbuild/compiler/sql_test_glue/helpers.py b/tests/integration/src/sqlbuild/compiler/sql_test_glue/helpers.py index cfbadc548b..8cc9dbc032 100644 --- a/tests/integration/src/sqlbuild/compiler/sql_test_glue/helpers.py +++ b/tests/integration/src/sqlbuild/compiler/sql_test_glue/helpers.py @@ -15,10 +15,12 @@ import sqlbuild._native as native_module from sqlbuild.adapter.contract.classes.base_adapter import BaseAdapter from sqlbuild.adapters.duckdb.classes.duckdb_adapter import DuckDbAdapter +from sqlbuild.compiler.compile._helpers.native_stages import sql_tests as sql_test_stage from sqlbuild.compiler.compile.models import ( CompiledModelSqlTestPayload, CompiledProject, CompiledSqlTest, + CompileProjectInputs, CompileSqlTestCte, ) from sqlbuild.compiler.discovery.main.discover import discover_project_inputs @@ -27,10 +29,15 @@ from sqlbuild.compiler.planner._helpers.sql_tests import native_planning from sqlbuild.compiler.planner.models import NativeSqlTestArtifact from sqlbuild.compiler.sql_test_glue.models import ( + NativeSqlTestAssembly, + NativeSqlTestAssemblyRequest, NativeSqlTestChainRequest, NativeSqlTestPlanningRequest, ) -from sqlbuild.compiler.sql_test_glue.types import NativeSqlTestPlanRow +from sqlbuild.compiler.sql_test_glue.types import ( + NativeSqlTestAssemblyRow, + NativeSqlTestPlanRow, +) @dataclass(frozen=True) @@ -284,13 +291,121 @@ def _model_test(*, rng: random.Random, index: int, shape: SqlTestCorpusShape) -> return f'TEST (name "generated_{index}"{window});\n\nWITH\n{body}\nSELECT 1\n' -def compiled_project(*, project_dir: Path, files: dict[str, str]) -> CompiledProject: - """Write and compile a project, keeping any compile diagnostics on it.""" +_CASE_VALUES: dict[str, tuple[str, ...]] = { + "string": ('"open"', '"O\\\'Brien"', '"caf\u00e9 \u2603"', '""', '"a\\\\b"', "null"), + "integer": ("0", "-7", "8", "9223372036854775807", "null"), + "boolean": ("true", "false", "null"), + "float": ("1.25", "-0.5", "0.1", "100.0", "0.0", "null"), + "decimal": ( + '"2.4700"', + '"-0.000"', + '"3.00"', + '"1E+5"', + '"0.0001230"', + '"12345678901234567890123456789.5"', + "null", + ), +} +_REFERENCING_HELPERS: tuple[str, ...] = ( + 'SELECT order_id, amount FROM __source("raw_orders")', + 'SELECT order_id, amount FROM __ref("stg_orders")', + 'SELECT region, label FROM __seed("regions")', + 'SELECT order_id, amount FROM __table_fn("table_fn__customer_orders")(1)', + "SELECT 1 AS order_id, 2 AS amount", +) +_MOCK_READS: tuple[str, ...] = ( + "SELECT * FROM {helper}", + "SELECT * FROM {upper_helper}", + "SELECT * FROM {via}", + "WITH {helper} AS (SELECT 1 AS order_id) SELECT * FROM {helper}", + "SELECT * FROM main.{helper}", + "SELECT * FROM {helper} WHERE order_id ==> 1", + "SELECT 1 AS order_id", +) +_ASSEMBLY_TARGETS: tuple[str, ...] = ("stg_orders", "int_orders", "fct_regions") + + +def generated_sql_test_assembly_files( + *, rng: random.Random, test_count: int, shape: SqlTestCorpusShape +) -> dict[str, str]: + """The planning projects plus typed parameter cases and mocks reading referencing helpers.""" + + files: dict[str, str] = generated_sql_test_files(rng=rng, test_count=test_count, shape=shape) + files.update( + (f"tests/unit/test_cases_{index}.sql", _case_test(rng=rng, index=index)) + for index in range(test_count) + ) + files.update( + (f"tests/unit/test_helper_mock_{index}.sql", _helper_mock_test(rng=rng, index=index)) + for index in range(test_count) + ) + return files + + +def _case_test(*, rng: random.Random, index: int) -> str: + """A parameterized test over random typed parameters and up to three random cases.""" + + kinds: list[str] = rng.sample(sorted(_CASE_VALUES), k=rng.randint(1, 3)) + parameters: str = ", ".join(f"p_{kind} (type {kind}, nullable true)" for kind in kinds) + cases: str = ", ".join( + f"case_{case} ({_case_values(rng=rng, kinds=kinds)})" for case in range(rng.randint(1, 3)) + ) + target: str = rng.choice(_ASSEMBLY_TARGETS) + used: str = ", ".join(f'@param("p_{kind}") AS p_{kind}' for kind in kinds) + return ( + f'TEST (name "cases_{index}", parameters ({parameters}), cases ({cases}));\n\n' + f"WITH\n{_UPSTREAM[target][0]} AS (SELECT 1 AS order_id, {used}),\n" + f'__assert__{target}_rows AS (SELECT * FROM __ref("{target}") WHERE 1 = 0)\nSELECT 1\n' + ) + + +def _case_values(*, rng: random.Random, kinds: list[str]) -> str: + return ", ".join(f"p_{kind} {rng.choice(_CASE_VALUES[kind])}" for kind in kinds) + + +def _helper_mock_test(*, rng: random.Random, index: int) -> str: + """A model test whose mocks read helpers, some of which call a reference.""" + + helper: str = f"source_rows_{index}" + via: str = f"via_{index}" + target: str = rng.choice(_ASSEMBLY_TARGETS) + mocks: list[str] = rng.sample(list(_UPSTREAM[target]), k=rng.randint(1, len(_UPSTREAM[target]))) + reads: dict[str, str] = {"helper": helper, "upper_helper": helper.upper(), "via": via} + mock_ctes: list[str] = [ + f"{mock} AS (\n {rng.choice(_MOCK_READS).format(**reads)}\n)" for mock in mocks + ] + check: str = rng.choice( + ( + f"__expected__{target} AS (SELECT order_id FROM {helper})", + f'__assert__{target}_rows AS (SELECT * FROM __ref("{target}") WHERE 1 = 0)', + f"__expected__{target} AS (SELECT 1 AS order_id)", + ) + ) + comment: str = rng.choice(("", "", "-- r\u00e9sum\u00e9\n")) + ctes: str = ",\n".join( + ( + f"{helper} AS (\n {rng.choice(_REFERENCING_HELPERS)}\n)", + f"{via} AS (SELECT * FROM {helper})", + *mock_ctes, + check, + ) + ) + return f'TEST (name "helper_mock_{index}");\n\nWITH\n{comment}{ctes}\nSELECT 1\n' + + +def write_project(*, project_dir: Path, files: dict[str, str]) -> None: + """Write a project's files below `project_dir`.""" for relative_path, contents in files.items(): path: Path = project_dir / relative_path path.parent.mkdir(parents=True, exist_ok=True) _ = path.write_text(contents, encoding="utf-8") + + +def compiled_project(*, project_dir: Path, files: dict[str, str]) -> CompiledProject: + """Write and compile a project, keeping any compile diagnostics on it.""" + + write_project(project_dir=project_dir, files=files) return build_project_graph( discovered_inputs=discover_project_inputs(project_dir=project_dir), adapter=DuckDbAdapter(), @@ -330,6 +445,65 @@ def use_sql_test_glue(*, monkeypatch: pytest.MonkeyPatch, enabled: bool) -> None ) +def assembly_outcome( + *, project_dir: Path, monkeypatch: pytest.MonkeyPatch, native: bool +) -> PlanningCallOutcome: + """The compiled SQL tests and diagnostics with native or Python assembly, or what it raises.""" + + monkeypatch.setattr( + sql_test_stage, + "native_stage_enabled", + lambda stage: native and stage is NativeStage.SQL_TEST_GLUE, + ) + return _outcome(lambda: _compiled_tests_and_diagnostics(project_dir=project_dir)) + + +def _compiled_tests_and_diagnostics(*, project_dir: Path) -> tuple[object, object]: + project: CompiledProject = build_project_graph( + discovered_inputs=discover_project_inputs(project_dir=project_dir), + adapter=DuckDbAdapter(), + ).project + return project.sql_tests, project.diagnostics + + +def record_native_assemblies(*, monkeypatch: pytest.MonkeyPatch) -> Counter[str]: + """Count tests compile takes from the native assembly, with diagnostics, cases, or defers. + + `native_assembled` counts only assemblies the stage seam hands compile; a deferred test + counts under `deferred_` and is assembled by Python, so it never counts as native work. + """ + + answers: Counter[str] = Counter() + assemble: Callable[[NativeSqlTestAssemblyRequest], list[NativeSqlTestAssemblyRow]] = ( + native_module.assemble_compiled_sql_tests + ) + seam: Callable[..., tuple[NativeSqlTestAssembly | None, ...]] = ( + sql_test_stage.assemble_native_sql_tests + ) + + def counted_deferrals(request: NativeSqlTestAssemblyRequest) -> list[NativeSqlTestAssemblyRow]: + rows: list[NativeSqlTestAssemblyRow] = assemble(request) + deferrals: list[str] = list(filter(None, (row[1] for row in rows))) + answers.update(f"deferred_{kind}" for kind in deferrals) + return rows + + def counted_assemblies( + *, inputs: CompileProjectInputs + ) -> tuple[NativeSqlTestAssembly | None, ...]: + assemblies: tuple[NativeSqlTestAssembly | None, ...] = seam(inputs=inputs) + native: list[NativeSqlTestAssembly] = list(filter(None, assemblies)) + answers["native_assembled"] += len(native) + answers["native_with_diagnostics"] += sum(bool(item.diagnostics) for item in native) + answers["native_case_fingerprints"] += sum( + item.test.case_fingerprint is not None for item in native + ) + return assemblies + + monkeypatch.setattr(native_module, "assemble_compiled_sql_tests", counted_deferrals) + monkeypatch.setattr(sql_test_stage, "assemble_native_sql_tests", counted_assemblies) + return answers + + def planning_outcome( *, project: CompiledProject, diff --git a/tests/integration/src/sqlbuild/compiler/sql_test_glue/test_native_sql_test_planning_glue.py b/tests/integration/src/sqlbuild/compiler/sql_test_glue/test_native_sql_test_planning_glue.py index c24cf34efd..22cc63e2ea 100644 --- a/tests/integration/src/sqlbuild/compiler/sql_test_glue/test_native_sql_test_planning_glue.py +++ b/tests/integration/src/sqlbuild/compiler/sql_test_glue/test_native_sql_test_planning_glue.py @@ -1,4 +1,4 @@ -"""SQL tests planned natively from compiled objects equal the JSON-request plans, test by test.""" +"""SQL tests assembled and planned natively equal Python's assembly and the JSON-request plans.""" from __future__ import annotations @@ -17,6 +17,7 @@ from sqlbuild.compiler.compile.models import CompiledProject, CompiledSqlTest from tests.integration.src.sqlbuild.compiler.helpers import mismatches from tests.integration.src.sqlbuild.compiler.sql_test_glue._test_types import ( + GeneratedSqlTestAssemblyParityTestCase, GeneratedSqlTestPlanningParityTestCase, ) from tests.integration.src.sqlbuild.compiler.sql_test_glue.helpers import ( @@ -26,15 +27,19 @@ PlanningCallOutcome, SqlTestCorpusShape, artifact_outcome, + assembly_outcome, chain_outcome, compiled_project, + generated_sql_test_assembly_files, generated_sql_test_files, native_raised_kinds, outcome_kind, planning_outcome, record_native_answers, + record_native_assemblies, use_sql_test_glue, with_unmocked_assertion, + write_project, ) _ADAPTERS: dict[str, BaseAdapter] = { @@ -171,3 +176,62 @@ def test_given_generated_sql_tests_when_planning_natively_then_plans_match_the_j outcomes["batch_answered"] >= test_case.expected_minimum_answered_batches, outcomes["raised"] >= test_case.expected_minimum_raised_outcomes, ) == ([], True, True, True, True, True, True, True), (native_answers, outcomes) + + +@pytest.mark.parametrize( + "test_case", + [ + GeneratedSqlTestAssemblyParityTestCase( + description="model, direct, parameterized and mock-reads-helper tests", + seed=20261010, + count=4, + test_count=12, + shape=SqlTestCorpusShape( + windows=(NO_WINDOW, VALID_WINDOW, INVERTED_WINDOW), + stray_window_share=0.1, + helper_redefinition_share=0.15, + assertion_share=0.2, + unflattenable_assertion_share=0.1, + ), + expected_native_assemblies={ + "native_assembled": 181, + "native_with_diagnostics": 17, + "native_case_fingerprints": 96, + "deferred_macro_mocks": 4, + "deferred_non_ascii_text": 11, + "deferred_decimal_context": 5, + }, + ) + ], + ids=lambda case: case.description, +) +def test_given_generated_sql_tests_when_assembling_natively_then_compiled_tests_match_python( + test_case: GeneratedSqlTestAssemblyParityTestCase, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + rng: random.Random = random.Random(test_case.seed) + native_assemblies: Counter[str] = record_native_assemblies(monkeypatch=monkeypatch) + differences: list[tuple[object, object, object]] = [] + outcomes: Counter[str] = Counter() + for index in range(test_case.count): + project_dir: Path = tmp_path / f"project_{index}" + write_project( + project_dir=project_dir, + files=generated_sql_test_assembly_files( + rng=rng, test_count=test_case.test_count, shape=test_case.shape + ), + ) + expected: PlanningCallOutcome = assembly_outcome( + project_dir=project_dir, monkeypatch=monkeypatch, native=False + ) + actual: PlanningCallOutcome = assembly_outcome( + project_dir=project_dir, monkeypatch=monkeypatch, native=True + ) + differences.extend(mismatches(inputs=[index], expected=[expected], actual=[actual])) + outcomes[outcome_kind(actual)] += 1 + + assert (differences, dict(native_assemblies)) == ( + [], + test_case.expected_native_assemblies, + ), outcomes