From 6838836b1470638d0f6c5f64ba9d0f22bb6263dc Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 9 Oct 2026 08:54:50 +0100 Subject: [PATCH 1/3] feat(native): assemble SQL test facts natively behind the preview engine --- .../compiler/_helpers/sql_tests/assembly.rs | 171 +++++ .../_helpers/sql_tests/case_identity.rs | 168 +++++ .../_helpers/sql_tests/macro_call_names.rs | 105 ++++ .../compiler/_helpers/sql_tests/mock_reads.rs | 588 ++++++++++++++++++ .../src/compiler/_helpers/sql_tests/mod.rs | 4 + .../src/compiler/main/mod.rs | 1 + .../src/compiler/main/sql_test_assembly.rs | 9 + .../sqlbuild-analysis/src/compiler/models.rs | 151 +++++ .../_helpers/sql_test_glue/assembly.rs | 253 ++++++++ .../bindings/_helpers/sql_test_glue/mod.rs | 1 + .../_helpers/sql_test_glue/registration.rs | 7 +- src/sqlbuild/_native.pyi | 6 +- .../_helpers/native_stages/sql_tests.py | 19 +- .../sql_test_glue/_helpers/__init__.py | 0 .../sql_test_glue/_helpers/assembly.py | 133 ++++ .../sql_test_glue/_helpers/deferrals.py | 24 + .../compiler/sql_test_glue/constants.py | 1 + .../main/_assemble_native_sql_tests.py | 30 +- src/sqlbuild/compiler/sql_test_glue/models.py | 27 +- src/sqlbuild/compiler/sql_test_glue/types.py | 12 +- .../compile/test_native_analysis_seams.py | 7 +- .../compiler/sql_test_glue/_test_types.py | 15 + .../compiler/sql_test_glue/helpers.py | 166 ++++- .../test_native_sql_test_planning_glue.py | 68 +- 24 files changed, 1945 insertions(+), 21 deletions(-) create mode 100644 crates/sqlbuild-analysis/src/compiler/_helpers/sql_tests/assembly.rs create mode 100644 crates/sqlbuild-analysis/src/compiler/_helpers/sql_tests/case_identity.rs create mode 100644 crates/sqlbuild-analysis/src/compiler/_helpers/sql_tests/macro_call_names.rs create mode 100644 crates/sqlbuild-analysis/src/compiler/_helpers/sql_tests/mock_reads.rs create mode 100644 crates/sqlbuild-analysis/src/compiler/main/sql_test_assembly.rs create mode 100644 crates/sqlbuild-python/src/bindings/_helpers/sql_test_glue/assembly.rs create mode 100644 src/sqlbuild/compiler/sql_test_glue/_helpers/__init__.py create mode 100644 src/sqlbuild/compiler/sql_test_glue/_helpers/assembly.py create mode 100644 src/sqlbuild/compiler/sql_test_glue/_helpers/deferrals.py 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..0e8866fc2e --- /dev/null +++ b/crates/sqlbuild-analysis/src/compiler/_helpers/sql_tests/assembly.rs @@ -0,0 +1,171 @@ +//! 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::mock_reading_helper_diagnostics; +use crate::compiler::models::{ + AssembledSqlTestFacts, SqlTestAssemblyBatch, SqlTestAssemblyDeferral, SqlTestAssemblyModel, + SqlTestAssemblyOutcome, SqlTestAssemblyPayload, SqlTestAssemblyTest, +}; + +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::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( + test, + payload, + models, + &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..bb695db494 --- /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'@' || !is_call_start(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 is_call_start(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..f2a2807103 --- /dev/null +++ b/crates/sqlbuild-analysis/src/compiler/_helpers/sql_tests/mock_reads.rs @@ -0,0 +1,588 @@ +//! Python's `report_mocks_reading_referencing_helpers`: mocks a test reads that read a helper +//! calling a reference, which cannot resolve because mocks are defined before the models run. + +use std::collections::HashSet; + +use polyglot_sql::{Dialect, DialectType, Expression, ExpressionWalk}; +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, +}; +use crate::lineage::_helpers::parsed_lineage::proxy_parse_options; + +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; + +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, +} + +/// Python's `SqlTestCteGraph`, keyed by case-folded CTE name in first-definition order. +struct CteGraph<'a> { + ctes: Vec<(String, &'a SqlTestCte)>, + reads: Vec<(String, Vec)>, + reader_reads: Vec, +} + +impl CteGraph<'_> { + fn cte(&self, key: &str) -> Option<&SqlTestCte> { + self.ctes + .iter() + .find(|(candidate, _)| candidate == key) + .map(|(_, cte)| *cte) + } + + fn reads(&self, key: &str) -> &[String] { + self.reads + .iter() + .find(|(candidate, _)| candidate == key) + .map_or(&[], |(_, reads)| reads.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( + test: &SqlTestAssemblyTest, + payload: &SqlTestAssemblyModelPayload, + models: &[SqlTestAssemblyModel], + target_model_names: &[String], + syntax: &LexicalSyntax, +) -> Deferrable> { + let mut referencing: Vec<(String, Reference)> = Vec::new(); + for cte in &payload.authored_ctes { + if is_mock_name(&cte.name) { + continue; + } + let first = references(&cte.sql_body, syntax)? + .into_iter() + .find(|reference| RELATION_KINDS.contains(&reference.kind.as_str())); + if let Some(reference) = first { + insert(&mut referencing, ascii_fold(&cte.name)?, reference); + } + } + if referencing.is_empty() { + return Ok(Vec::new()); + } + require_ascii(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 called_mocks: Vec = Vec::new(); + let read_helpers = read_helper_keys(&graph); + let helper_sqls = read_helpers + .iter() + .filter_map(|key| graph.cte(key).map(|cte| cte.sql_body.as_str())); + for sql in readers + .iter() + .map(|cte| cte.sql_body.as_str()) + .chain(helper_sqls) + { + for reference in references(sql, syntax)? { + called_mocks.extend(mock_keys(&reference)?); + } + } + let used = used_mock_keys( + &graph, + &called_mocks, + models, + target_model_names, + &payload.mock_model_names, + )?; + let mut diagnostics: Vec = Vec::new(); + for mock_key in used { + let reachable = graph.reachable(graph.reads(&mock_key)); + let Some((helper_key, reference)) = reachable.iter().find_map(|key| { + referencing + .iter() + .find(|(candidate, _)| candidate == key) + .map(|(_, reference)| (key, reference)) + }) else { + continue; + }; + let (Some(mock), Some(helper)) = (graph.cte(&mock_key), graph.cte(helper_key)) else { + continue; + }; + let call = reference_call(reference); + let 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 + ); + let help = mock_reference_help(&mock.name, &call, reference); + diagnostics.push(located(test, &helper.name, &call, message, help)); + } + Ok(diagnostics) +} + +fn is_mock_name(name: &str) -> bool { + MOCK_CTE_PREFIXES + .iter() + .any(|prefix| name.starts_with(prefix)) +} + +/// Python dict assignment: a repeated key keeps its first position and takes the new value. +fn insert(entries: &mut Vec<(String, T)>, key: String, value: T) { + match entries.iter_mut().find(|(candidate, _)| *candidate == key) { + Some(entry) => entry.1 = value, + None => entries.push((key, value)), + } +} + +/// 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: Vec<(String, &'a SqlTestCte)> = Vec::new(); + for cte in authored { + insert(&mut ctes, ascii_fold(&cte.name)?, cte); + } + let keys: Vec = ctes.iter().map(|(key, _)| key.clone()).collect(); + let mut reads: Vec<(String, Vec)> = Vec::new(); + for (key, cte) in &ctes { + 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.push((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 Ok(options) = proxy_parse_options() else { + return Err(SqlTestAssemblyDeferral::UnreadableTree); + }; + 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)) +} + +/// 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( + graph: &CteGraph<'_>, + called_mocks: &[String], + models: &[SqlTestAssemblyModel], + target_model_names: &[String], + mock_model_names: &[String], +) -> Deferrable> { + 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: Vec<(String, &[SqlTestAssemblyReference])> = Vec::new(); + for model in models { + insert( + &mut model_references, + model.name.clone(), + model.references.as_slice(), + ); + } + let mut pending: Vec = 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 = model_references + .iter() + .find(|(name, _)| *name == model_name) + .map_or(&[][..], |(_, references)| *references); + 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, + message: String, + help: String, +) -> SqlTestHelperDiagnostic { + 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 end = start + length; + let (line, column) = line_and_column(contents, start); + let (end_line, end_column) = line_and_column(contents, end); + SqlTestHelperDiagnostic { + line, + column, + end_line, + end_column, + message, + help, + } +} + +/// 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..7ef1d147f3 100644 --- a/crates/sqlbuild-analysis/src/compiler/models.rs +++ b/crates/sqlbuild-analysis/src/compiler/models.rs @@ -182,3 +182,154 @@ 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, + /// The pre-macro SQL to scan for calls when no macro dependencies were recorded and it + /// contains a macro token; Python scans it only then. + 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-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/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..0ed42636e2 --- /dev/null +++ b/src/sqlbuild/compiler/sql_test_glue/_helpers/assembly.py @@ -0,0 +1,133 @@ +"""Compiled SQL tests built from the facts the native assembly returns.""" + +from __future__ import annotations + +from sqlbuild.compiler.compile.constants import SQL_TEST_HELPER_REFERENCE_CODE +from sqlbuild.compiler.compile.models import ( + CompiledDirectLogicSqlTestPayload, + CompileDirectLogicSqlTestInputPayload, + CompiledModelSqlTestPayload, + CompiledObjectKey, + CompiledSqlTest, + CompiledSqlTestResource, + CompilerDiagnostic, + CompileSqlTestInput, +) +from sqlbuild.compiler.compile.types import ( + CompiledResourceType, + DiagnosticPhase, + DiagnosticSeverity, +) +from sqlbuild.compiler.sql_test_glue.models import NativeSqlTestAssembly +from sqlbuild.compiler.sql_test_glue.types import ( + NativeSqlTestDiagnosticRow, + NativeSqlTestFactsRow, +) +from sqlbuild.spec.contracts.models import SourceLocation + + +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 = 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 = 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..c4f6a144d2 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,34 @@ 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_assembly +from sqlbuild.compiler.sql_test_glue._helpers.deferrals import record_sql_test_assembly_deferral +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, + ) + ) + assemblies: list[NativeSqlTestAssembly | None] = [] + for test_input, (facts, deferral) in zip(inputs.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) 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..4916b98965 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,18 @@ 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_minimum_native_assembled: int + expected_minimum_native_with_diagnostics: int + expected_minimum_native_case_fingerprints: int + expected_minimum_deferred_macro_mocks: 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..610bfc9731 100644 --- a/tests/integration/src/sqlbuild/compiler/sql_test_glue/helpers.py +++ b/tests/integration/src/sqlbuild/compiler/sql_test_glue/helpers.py @@ -15,6 +15,7 @@ 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, @@ -27,10 +28,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 ( + NativeSqlTestAssemblyRequest, NativeSqlTestChainRequest, NativeSqlTestPlanningRequest, ) -from sqlbuild.compiler.sql_test_glue.types import NativeSqlTestPlanRow +from sqlbuild.compiler.sql_test_glue.types import ( + NativeSqlTestAssemblyRow, + NativeSqlTestFactsRow, + NativeSqlTestPlanRow, +) @dataclass(frozen=True) @@ -284,13 +290,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 +444,52 @@ 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 the native assembly itself assembles, with diagnostics, cases, or defers. + + `native_assembled` counts only rows carrying native facts; 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 + ) + + def counted(request: NativeSqlTestAssemblyRequest) -> list[NativeSqlTestAssemblyRow]: + rows: list[NativeSqlTestAssemblyRow] = assemble(request) + facts: list[NativeSqlTestFactsRow] = [row[0] for row in rows if row[0] is not None] + answers["native_assembled"] += len(facts) + answers["native_with_diagnostics"] += sum(bool(row[5]) for row in facts) + answers["native_case_fingerprints"] += sum(row[3] is not None for row in facts) + answers.update(f"deferred_{row[1]}" for row in rows if row[1] is not None) + return rows + + monkeypatch.setattr(native_module, "assemble_compiled_sql_tests", counted) + 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..48ac5b75ac 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,64 @@ 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_minimum_native_assembled=140, + expected_minimum_native_with_diagnostics=18, + expected_minimum_native_case_fingerprints=65, + expected_minimum_deferred_macro_mocks=4, + ) + ], + 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, + native_assemblies["native_assembled"] >= test_case.expected_minimum_native_assembled, + native_assemblies["native_with_diagnostics"] + >= test_case.expected_minimum_native_with_diagnostics, + native_assemblies["native_case_fingerprints"] + >= test_case.expected_minimum_native_case_fingerprints, + native_assemblies["deferred_macro_mocks"] + >= test_case.expected_minimum_deferred_macro_mocks, + ) == ([], True, True, True, True), (native_assemblies, outcomes) From 4182487e7439bace687c1d8aa9ae48122df67e11 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 9 Oct 2026 09:50:16 +0100 Subject: [PATCH 2/3] refactor(native): bundle SQL test assembly inputs and count only consumed native assemblies --- .../compiler/_helpers/sql_tests/assembly.rs | 14 +- .../_helpers/sql_tests/macro_call_names.rs | 4 +- .../compiler/_helpers/sql_tests/mock_reads.rs | 233 ++++++++++-------- .../sqlbuild-analysis/src/compiler/models.rs | 3 +- .../tests/test_sql_test_helper_references.rs | 216 +++++++++++++++- .../src/compiler/tests/test_types.rs | 6 + .../_helpers/analysis_corpus/sql_test_glue.py | 21 +- .../sql_test_glue/_helpers/assembly.py | 26 +- .../main/_assemble_native_sql_tests.py | 12 +- .../compiler/sql_test_glue/helpers.py | 36 ++- .../test_native_sql_test_planning_glue.py | 8 +- 11 files changed, 440 insertions(+), 139 deletions(-) diff --git a/crates/sqlbuild-analysis/src/compiler/_helpers/sql_tests/assembly.rs b/crates/sqlbuild-analysis/src/compiler/_helpers/sql_tests/assembly.rs index 0e8866fc2e..6836d3cf0f 100644 --- a/crates/sqlbuild-analysis/src/compiler/_helpers/sql_tests/assembly.rs +++ b/crates/sqlbuild-analysis/src/compiler/_helpers/sql_tests/assembly.rs @@ -5,10 +5,12 @@ 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::mock_reading_helper_diagnostics; +use crate::compiler::_helpers::sql_tests::mock_reads::{ + MockReadsRequest, mock_reading_helper_diagnostics, +}; use crate::compiler::models::{ AssembledSqlTestFacts, SqlTestAssemblyBatch, SqlTestAssemblyDeferral, SqlTestAssemblyModel, - SqlTestAssemblyOutcome, SqlTestAssemblyPayload, SqlTestAssemblyTest, + SqlTestAssemblyOutcome, SqlTestAssemblyPayload, SqlTestAssemblyTest, SqlTestHelperDiagnostic, }; const MODEL_RESOURCE: &str = "model"; @@ -63,7 +65,7 @@ fn assemble( 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::new(); + let mut diagnostics: Vec = Vec::new(); let scope_deps: Vec<(&'static str, String)> = match &test.payload { SqlTestAssemblyPayload::Direct { mode, @@ -89,13 +91,13 @@ fn assemble( target_model_names.push(name.clone()); } } - diagnostics = mock_reading_helper_diagnostics( + diagnostics = mock_reading_helper_diagnostics(&MockReadsRequest { test, payload, models, - &target_model_names, + target_model_names: &target_model_names, syntax, - )?; + })?; target_model_names .iter() .map(|name| (MODEL_RESOURCE, name.clone())) 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 index bb695db494..38c0421c61 100644 --- 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 @@ -25,7 +25,7 @@ pub(crate) fn macro_call_names(sql: &str) -> Scan> { Ok(None) => {} Err(_) => return Err(SqlTestAssemblyDeferral::MacroCallScan), } - if bytes[index] != b'@' || !is_call_start(bytes, index)? { + if bytes[index] != b'@' || !macro_call_at(bytes, index)? { index += 1; continue; } @@ -47,7 +47,7 @@ pub(crate) fn macro_call_names(sql: &str) -> Scan> { } /// Python's `_is_macro_call_start`, which skips whitespace after each name character. -fn is_call_start(bytes: &[u8], at: usize) -> Scan { +fn macro_call_at(bytes: &[u8], at: usize) -> Scan { let Some(&first) = bytes.get(at + 1) else { return Ok(false); }; 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 index f2a2807103..f381916286 100644 --- a/crates/sqlbuild-analysis/src/compiler/_helpers/sql_tests/mock_reads.rs +++ b/crates/sqlbuild-analysis/src/compiler/_helpers/sql_tests/mock_reads.rs @@ -1,9 +1,10 @@ -//! Python's `report_mocks_reading_referencing_helpers`: mocks a test reads that read a helper -//! calling a reference, which cannot resolve because mocks are defined before the models run. +//! Python's `report_mocks_reading_referencing_helpers`: P013s for mocks reading referencing helpers. use std::collections::HashSet; -use polyglot_sql::{Dialect, DialectType, Expression, ExpressionWalk}; +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}; @@ -14,7 +15,6 @@ use crate::compiler::models::{ SqlTestAssemblyDeferral, SqlTestAssemblyModel, SqlTestAssemblyModelPayload, SqlTestAssemblyReference, SqlTestAssemblyTest, SqlTestCte, SqlTestHelperDiagnostic, }; -use crate::lineage::_helpers::parsed_lineage::proxy_parse_options; const REF_KIND: &str = "ref"; const TABLE_FUNCTION_KIND: &str = "table_fn"; @@ -28,6 +28,8 @@ const RELATION_KINDS: [&str; 5] = [ ]; /// 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; @@ -39,26 +41,72 @@ struct Reference { 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: Vec<(String, &'a SqlTestCte)>, - reads: Vec<(String, Vec)>, + 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 - .iter() - .find(|(candidate, _)| candidate == key) - .map(|(_, cte)| *cte) + self.ctes.get(key).copied() } fn reads(&self, key: &str) -> &[String] { - self.reads - .iter() - .find(|(candidate, _)| candidate == key) - .map_or(&[], |(_, reads)| reads.as_slice()) + self.reads.get(key).map_or(&[], Vec::as_slice) } /// Python's `reachable_cte_keys`: breadth first, roots included, in visit order. @@ -92,77 +140,74 @@ impl CteGraph<'_> { /// The P013 diagnostics Python reports for one model test, in report order. pub(crate) fn mock_reading_helper_diagnostics( - test: &SqlTestAssemblyTest, - payload: &SqlTestAssemblyModelPayload, - models: &[SqlTestAssemblyModel], - target_model_names: &[String], - syntax: &LexicalSyntax, + request: &MockReadsRequest<'_>, ) -> Deferrable> { - let mut referencing: Vec<(String, Reference)> = Vec::new(); + 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, syntax)? + let first = references(&cte.sql_body, request.syntax)? .into_iter() .find(|reference| RELATION_KINDS.contains(&reference.kind.as_str())); if let Some(reference) = first { - insert(&mut referencing, ascii_fold(&cte.name)?, reference); + referencing.assign(ascii_fold(&cte.name)?, reference); } } - if referencing.is_empty() { + if referencing.entries.is_empty() { return Ok(Vec::new()); } - require_ascii(test, payload)?; + 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(); - let read_helpers = read_helper_keys(&graph); - let helper_sqls = read_helpers - .iter() - .filter_map(|key| graph.cte(key).map(|cte| cte.sql_body.as_str())); - for sql in readers - .iter() - .map(|cte| cte.sql_body.as_str()) - .chain(helper_sqls) - { - for reference in references(sql, syntax)? { + for sql in scanned { + for reference in references(sql, request.syntax)? { called_mocks.extend(mock_keys(&reference)?); } } - let used = used_mock_keys( - &graph, - &called_mocks, - models, - target_model_names, - &payload.mock_model_names, - )?; + let used = used_mock_keys(request, &graph, &called_mocks)?; let mut diagnostics: Vec = Vec::new(); for mock_key in used { - let reachable = graph.reachable(graph.reads(&mock_key)); - let Some((helper_key, reference)) = reachable.iter().find_map(|key| { - referencing - .iter() - .find(|(candidate, _)| candidate == key) - .map(|(_, reference)| (key, reference)) - }) else { + 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 { + let (Some(mock), Some(helper)) = (graph.cte(&mock_key), graph.cte(&helper_key)) else { continue; }; let call = reference_call(reference); - let 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 - ); - let help = mock_reference_help(&mock.name, &call, reference); - diagnostics.push(located(test, &helper.name, &call, message, help)); + 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) } @@ -173,14 +218,6 @@ fn is_mock_name(name: &str) -> bool { .any(|prefix| name.starts_with(prefix)) } -/// Python dict assignment: a repeated key keeps its first position and takes the new value. -fn insert(entries: &mut Vec<(String, T)>, key: String, value: T) { - match entries.iter_mut().find(|(candidate, _)| *candidate == key) { - Some(entry) => entry.1 = value, - None => entries.push((key, value)), - } -} - /// Python's `str.casefold`, exact only on ASCII text. fn ascii_fold(text: &str) -> Deferrable { if text.is_ascii() { @@ -240,19 +277,19 @@ fn model_reference(reference: &SqlTestAssemblyReference) -> Reference { /// Python's `sql_test_cte_graph`. fn cte_graph<'a>(authored: &'a [SqlTestCte], readers: &[&SqlTestCte]) -> Deferrable> { - let mut ctes: Vec<(String, &'a SqlTestCte)> = Vec::new(); + let mut ctes: OrderedEntries<&'a SqlTestCte> = OrderedEntries::new(); for cte in authored { - insert(&mut ctes, ascii_fold(&cte.name)?, cte); + ctes.assign(ascii_fold(&cte.name)?, cte); } - let keys: Vec = ctes.iter().map(|(key, _)| key.clone()).collect(); - let mut reads: Vec<(String, Vec)> = Vec::new(); - for (key, cte) in &ctes { + 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.push((key.clone(), cte_reads)); + reads.assign(key.clone(), cte_reads); } let mut reader_reads: Vec = Vec::new(); for reader in readers { @@ -279,9 +316,7 @@ fn parsed_reads(sql: &str, keys: &[String]) -> Deferrable>> { if !keys.iter().any(|key| folded.contains(key.as_str())) { return Ok(Some(Vec::new())); } - let Ok(options) = proxy_parse_options() else { - return Err(SqlTestAssemblyDeferral::UnreadableTree); - }; + let options = proxy_parse_options()?; let Ok(mut statements) = Dialect::get(DialectType::Generic).parse_with_options(sql, &options) else { return Ok(None); @@ -307,6 +342,17 @@ fn parsed_reads(sql: &str, keys: &[String]) -> Deferrable>> { 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)?; @@ -395,12 +441,11 @@ fn mock_keys(reference: &Reference) -> Deferrable> { /// Python's `_used_mock_keys`. fn used_mock_keys( + request: &MockReadsRequest<'_>, graph: &CteGraph<'_>, called_mocks: &[String], - models: &[SqlTestAssemblyModel], - target_model_names: &[String], - mock_model_names: &[String], ) -> Deferrable> { + let mock_model_names = &request.payload.mock_model_names; let mut used: Vec = graph .reachable(&graph.reader_reads) .into_iter() @@ -416,15 +461,12 @@ fn used_mock_keys( used.push(key.clone()); } } - let mut model_references: Vec<(String, &[SqlTestAssemblyReference])> = Vec::new(); - for model in models { - insert( - &mut model_references, - model.name.clone(), - model.references.as_slice(), - ); + 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 = target_model_names + let mut pending: Vec = request + .target_model_names .iter() .filter(|name| !mock_model_names.contains(name)) .cloned() @@ -434,10 +476,10 @@ fn used_mock_keys( while cursor < pending.len() { let model_name = pending[cursor].clone(); cursor += 1; - let references = model_references - .iter() - .find(|(name, _)| *name == model_name) - .map_or(&[][..], |(_, references)| *references); + 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)? { @@ -487,13 +529,7 @@ fn mock_reference_help(mock_name: &str, call: &str, reference: &Reference) -> St } /// Python's `sql_test_cte_location` and `reference_call_location` on ASCII contents. -fn located( - test: &SqlTestAssemblyTest, - cte_name: &str, - call: &str, - message: String, - help: String, -) -> SqlTestHelperDiagnostic { +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) { @@ -501,16 +537,13 @@ fn located( .map_or((header_start, cte_name.len()), |found| (found, call.len())), None => (block_offset, 0), }; - let end = start + length; let (line, column) = line_and_column(contents, start); - let (end_line, end_column) = line_and_column(contents, end); - SqlTestHelperDiagnostic { + let (end_line, end_column) = line_and_column(contents, start + length); + Span { line, column, end_line, end_column, - message, - help, } } diff --git a/crates/sqlbuild-analysis/src/compiler/models.rs b/crates/sqlbuild-analysis/src/compiler/models.rs index 7ef1d147f3..ca6719b481 100644 --- a/crates/sqlbuild-analysis/src/compiler/models.rs +++ b/crates/sqlbuild-analysis/src/compiler/models.rs @@ -196,8 +196,7 @@ pub struct SqlTestAssemblyBatch { pub struct SqlTestAssemblyModel { pub name: String, pub macro_deps: Vec, - /// The pre-macro SQL to scan for calls when no macro dependencies were recorded and it - /// contains a macro token; Python scans it only then. + /// 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, } 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/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/compiler/sql_test_glue/_helpers/assembly.py b/src/sqlbuild/compiler/sql_test_glue/_helpers/assembly.py index 0ed42636e2..cb452078f9 100644 --- a/src/sqlbuild/compiler/sql_test_glue/_helpers/assembly.py +++ b/src/sqlbuild/compiler/sql_test_glue/_helpers/assembly.py @@ -2,6 +2,8 @@ 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, @@ -10,6 +12,7 @@ CompiledObjectKey, CompiledSqlTest, CompiledSqlTestResource, + CompileModelSqlTestInputPayload, CompilerDiagnostic, CompileSqlTestInput, ) @@ -18,14 +21,31 @@ 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: @@ -36,7 +56,9 @@ def native_sql_test_assembly( CompiledObjectKey(resource_type=resource_type, name=dep_name) for resource_type, dep_name in scope_dep_rows ) - payload = test_input.payload + payload: CompileModelSqlTestInputPayload | CompileDirectLogicSqlTestInputPayload = ( + test_input.payload + ) compiled_payload: CompiledModelSqlTestPayload | CompiledDirectLogicSqlTestPayload tested_resources: tuple[CompiledSqlTestResource, ...] = () expected_model_names: tuple[str, ...] = () @@ -115,7 +137,7 @@ 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 = test_input.test_file.relative_path + path: Path = test_input.test_file.relative_path return ( (SQL_TEST_HELPER_REFERENCE_CODE, path.as_posix(), f"{line:09d}:{column:09d}", message), CompilerDiagnostic( 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 c4f6a144d2..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 @@ -4,8 +4,7 @@ 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_assembly -from sqlbuild.compiler.sql_test_glue._helpers.deferrals import record_sql_test_assembly_deferral +from sqlbuild.compiler.sql_test_glue._helpers.assembly import native_sql_test_assemblies from sqlbuild.compiler.sql_test_glue.models import ( NativeSqlTestAssembly, NativeSqlTestAssemblyRequest, @@ -25,11 +24,4 @@ def assemble_native_sql_tests( lexical_syntax=inputs.sql_lexical_syntax.native_mapping, ) ) - assemblies: list[NativeSqlTestAssembly | None] = [] - for test_input, (facts, deferral) in zip(inputs.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) + return native_sql_test_assemblies(test_inputs=inputs.test_inputs, rows=rows) 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 610bfc9731..8cc9dbc032 100644 --- a/tests/integration/src/sqlbuild/compiler/sql_test_glue/helpers.py +++ b/tests/integration/src/sqlbuild/compiler/sql_test_glue/helpers.py @@ -20,6 +20,7 @@ CompiledModelSqlTestPayload, CompiledProject, CompiledSqlTest, + CompileProjectInputs, CompileSqlTestCte, ) from sqlbuild.compiler.discovery.main.discover import discover_project_inputs @@ -28,13 +29,13 @@ 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 ( NativeSqlTestAssemblyRow, - NativeSqlTestFactsRow, NativeSqlTestPlanRow, ) @@ -466,27 +467,40 @@ def _compiled_tests_and_diagnostics(*, project_dir: Path) -> tuple[object, objec def record_native_assemblies(*, monkeypatch: pytest.MonkeyPatch) -> Counter[str]: - """Count tests the native assembly itself assembles, with diagnostics, cases, or defers. + """Count tests compile takes from the native assembly, with diagnostics, cases, or defers. - `native_assembled` counts only rows carrying native facts; a deferred test counts under - `deferred_` and is assembled by Python, so it never counts as native work. + `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(request: NativeSqlTestAssemblyRequest) -> list[NativeSqlTestAssemblyRow]: + def counted_deferrals(request: NativeSqlTestAssemblyRequest) -> list[NativeSqlTestAssemblyRow]: rows: list[NativeSqlTestAssemblyRow] = assemble(request) - facts: list[NativeSqlTestFactsRow] = [row[0] for row in rows if row[0] is not None] - answers["native_assembled"] += len(facts) - answers["native_with_diagnostics"] += sum(bool(row[5]) for row in facts) - answers["native_case_fingerprints"] += sum(row[3] is not None for row in facts) - answers.update(f"deferred_{row[1]}" for row in rows if row[1] is not None) + deferrals: list[str] = list(filter(None, (row[1] for row in rows))) + answers.update(f"deferred_{kind}" for kind in deferrals) return rows - monkeypatch.setattr(native_module, "assemble_compiled_sql_tests", counted) + 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 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 48ac5b75ac..255a973be6 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 @@ -193,10 +193,10 @@ def test_given_generated_sql_tests_when_planning_natively_then_plans_match_the_j assertion_share=0.2, unflattenable_assertion_share=0.1, ), - expected_minimum_native_assembled=140, - expected_minimum_native_with_diagnostics=18, - expected_minimum_native_case_fingerprints=65, - expected_minimum_deferred_macro_mocks=4, + expected_minimum_native_assembled=150, + expected_minimum_native_with_diagnostics=13, + expected_minimum_native_case_fingerprints=80, + expected_minimum_deferred_macro_mocks=3, ) ], ids=lambda case: case.description, From 194b2804b8aa599475b9c97bbcb8f8f99c1aa573 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Fri, 9 Oct 2026 12:27:27 +0100 Subject: [PATCH 3/3] test(native): assert exact seeded SQL-test assembly counts and deferrals --- .../compiler/sql_test_glue/_test_types.py | 5 +--- .../test_native_sql_test_planning_glue.py | 26 +++++++++---------- 2 files changed, 13 insertions(+), 18 deletions(-) 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 4916b98965..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 @@ -33,7 +33,4 @@ class GeneratedSqlTestAssemblyParityTestCase: count: int test_count: int shape: SqlTestCorpusShape - expected_minimum_native_assembled: int - expected_minimum_native_with_diagnostics: int - expected_minimum_native_case_fingerprints: int - expected_minimum_deferred_macro_mocks: int + expected_native_assemblies: dict[str, int] 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 255a973be6..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 @@ -193,10 +193,14 @@ def test_given_generated_sql_tests_when_planning_natively_then_plans_match_the_j assertion_share=0.2, unflattenable_assertion_share=0.1, ), - expected_minimum_native_assembled=150, - expected_minimum_native_with_diagnostics=13, - expected_minimum_native_case_fingerprints=80, - expected_minimum_deferred_macro_mocks=3, + 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, @@ -227,13 +231,7 @@ def test_given_generated_sql_tests_when_assembling_natively_then_compiled_tests_ differences.extend(mismatches(inputs=[index], expected=[expected], actual=[actual])) outcomes[outcome_kind(actual)] += 1 - assert ( - differences, - native_assemblies["native_assembled"] >= test_case.expected_minimum_native_assembled, - native_assemblies["native_with_diagnostics"] - >= test_case.expected_minimum_native_with_diagnostics, - native_assemblies["native_case_fingerprints"] - >= test_case.expected_minimum_native_case_fingerprints, - native_assemblies["deferred_macro_mocks"] - >= test_case.expected_minimum_deferred_macro_mocks, - ) == ([], True, True, True, True), (native_assemblies, outcomes) + assert (differences, dict(native_assemblies)) == ( + [], + test_case.expected_native_assemblies, + ), outcomes