diff --git a/crates/sqlbuild-analysis/src/compiler/_helpers/sql_tests/authored_macro_calls.rs b/crates/sqlbuild-analysis/src/compiler/_helpers/sql_tests/authored_macro_calls.rs new file mode 100644 index 0000000000..c90c07952b --- /dev/null +++ b/crates/sqlbuild-analysis/src/compiler/_helpers/sql_tests/authored_macro_calls.rs @@ -0,0 +1,88 @@ +//! Whether macro expansion would expand a call in one unexpanded SQL test body. + +use sqlbuild_core::text::main::is_python_space::is_python_space; +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; + +/// Declaration calls that expansion substitutes rather than running as macros. +const DECLARATION_CALLS: [&str; 2] = ["enum", "const"]; + +/// Whether expansion's call scanner finds a macro call; text it cannot scan leaves expansion to fail. +pub(crate) fn calls_macro(sql: &str) -> bool { + if !sql.contains('@') { + return false; + } + let bytes = sql.as_bytes(); + 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 false, + } + if bytes[index] == b'@' + && let Some(open) = call_open(sql, index) + { + if !DECLARATION_CALLS.contains(&name_at(sql, index + 1)) { + return true; + } + let Ok(close) = matching_paren(bytes, open, QuotePolicy::COMPILER) else { + return false; + }; + index = close + 1; + continue; + } + index += char_at(sql, index).map_or(1, char::len_utf8); + } + false +} + +/// Python `_is_macro_call_start`, which also skips whitespace between name characters. +fn call_open(sql: &str, at: usize) -> Option { + let first = char_at(sql, at + 1)?; + if !(first == '_' || (first.is_alphabetic() && !first.is_numeric())) { + return None; + } + let mut cursor = at + 1 + first.len_utf8(); + while let Some(character) = char_at(sql, cursor) { + if !is_identifier_continue(character) { + break; + } + cursor = space_end(sql, cursor + character.len_utf8()); + } + (sql.as_bytes().get(cursor) == Some(&b'(')).then_some(cursor) +} + +fn name_at(sql: &str, start: usize) -> &str { + let mut end = start; + while let Some(character) = char_at(sql, end) { + if !is_identifier_continue(character) { + break; + } + end += character.len_utf8(); + } + &sql[start..end] +} + +fn is_identifier_continue(character: char) -> bool { + character == '_' || character.is_alphanumeric() +} + +fn space_end(sql: &str, start: usize) -> usize { + let mut index = start; + while let Some(character) = char_at(sql, index) { + if !is_python_space(character) { + break; + } + index += character.len_utf8(); + } + index +} + +fn char_at(sql: &str, index: usize) -> Option { + sql.get(index..).and_then(|rest| rest.chars().next()) +} diff --git a/crates/sqlbuild-analysis/src/compiler/_helpers/sql_tests/extraction.rs b/crates/sqlbuild-analysis/src/compiler/_helpers/sql_tests/extraction.rs index 031df7a8c9..215f896d8a 100644 --- a/crates/sqlbuild-analysis/src/compiler/_helpers/sql_tests/extraction.rs +++ b/crates/sqlbuild-analysis/src/compiler/_helpers/sql_tests/extraction.rs @@ -1,19 +1,20 @@ -//! Batched extraction and classification of already-expanded SQL tests. +//! Batched extraction and classification of SQL tests before and after expansion. use std::collections::{HashMap, HashSet, VecDeque}; use serde::{Deserialize, Serialize}; +use crate::compiler::_helpers::sql_tests::authored_macro_calls::calls_macro; +use crate::compiler::_helpers::sql_tests::reference_calls::body_calls; use crate::constants::{ - DIRECT_DEPENDENCY_PATH_LENGTH, MACRO_TEST_MODE, TABLE_FUNCTION_TEST_MODE, UDF_TEST_MODE, + DIRECT_DEPENDENCY_PATH_LENGTH, MACRO_TEST_MODE, OPERAND_KEYWORDS, OPERATOR_CHARACTERS, + SELECT_STAR_PROJECTION, TABLE_FUNCTION_TEST_MODE, UDF_TEST_MODE, VALUE_KEYWORDS, }; -use sqlbuild_sqltext::sql_scan::main::comment_end::comment_end; -use sqlbuild_sqltext::sql_scan::main::matching_paren::matching_paren as scan_matching_paren; -use sqlbuild_sqltext::sql_scan::main::non_code_end::non_code_end; -use sqlbuild_sqltext::sql_scan::main::quote_end::quote_end; -use sqlbuild_sqltext::sql_scan::main::skip_whitespace::skip_whitespace; -use sqlbuild_sqltext::sql_scan::models::QuotePolicy; -use sqlbuild_sqltext::sql_scan::models::Unclosed; +use sqlbuild_core::text::main::is_python_space::is_python_space; +use sqlbuild_core::text::main::python_strip::python_strip; +use sqlbuild_sqltext::sql_scan::main::dialect_matching_paren::dialect_matching_paren; +use sqlbuild_sqltext::sql_scan::main::dialect_non_code_end::dialect_non_code_end; +use sqlbuild_sqltext::sql_scan::models::{LexicalSyntax, Unclosed}; const MODEL_PREFIXES: [&str; 8] = [ "__macro__", @@ -35,6 +36,8 @@ const DIRECT_NAMES: [(&str, &str, &str); 3] = [ ), ]; const DECLARATION_CALLS: [&str; 4] = ["enum", "const", "var", "param"]; +/// Longest projection the alias help repeats verbatim. +const ALIAS_HELP_EXPRESSION_LIMIT: usize = 80; const RESERVED_NAMES: [&str; 5] = [ "__actual", "__expected__typed", @@ -47,6 +50,9 @@ const RESERVED_NAMES: [&str; 5] = [ #[serde(rename_all = "camelCase")] struct BatchRequest { tests: Vec, + /// The adapter's lexical rules; generic SQL when absent. + #[serde(default)] + syntax: Option, } #[derive(Debug, Deserialize)] @@ -55,6 +61,57 @@ struct TestRequest { sql: String, file_label: String, mode: String, + /// The test before macro and variable expansion, where macro calls are still authored. + #[serde(default)] + raw: bool, + /// Read only the CTE names and body offsets of the authored block, before any expansion. + #[serde(default)] + authored: bool, +} + +/// How strictly top-level CTE names are read. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum NameRule { + /// Unquoted ASCII identifiers only; anything else is an error naming the CTE. + Plain, + /// Quoted and Unicode identifiers are read, and the caller decides what to do with them. + Lenient, +} + +/// One extraction error, with help and the offending text where the author can act on it. +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct Failure { + pub(crate) message: String, + pub(crate) help: Option, + /// The authored text the error is about and its code-point offset in the test SQL. + pub(crate) token: Option<(String, usize)>, +} + +impl From for Failure { + fn from(message: String) -> Self { + Self { + message, + help: None, + token: None, + } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ErrorResponse { + index: usize, + message: String, + help: Option, + token: Option, + token_offset: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +enum BatchResponse { + Tests(Vec), + Error(ErrorResponse), } #[derive(Clone, Debug, PartialEq, Eq, Serialize)] @@ -63,6 +120,8 @@ pub(crate) struct Cte(pub(crate) String, pub(crate) String); #[derive(Debug, PartialEq, Eq, Serialize)] #[serde(tag = "kind", rename_all = "snake_case")] enum Classified { + /// Each CTE's name, its code-point offset in the authored block and its stripped body. + Authored { ctes: Vec<(String, usize, String)> }, Model { mode: String, authored: Vec, @@ -90,81 +149,243 @@ enum Classified { helpers: Vec, actual: Cte, expected: Cte, + /// Whether a helper or the expected CTE has a malformed reference call (P012). + #[serde(rename = "invalidCalls")] + invalid_calls: bool, }, } pub(crate) fn extract_batch_json(request_json: &str) -> Result { let request: BatchRequest = serde_json::from_str(request_json).map_err(|error| error.to_string())?; + let syntax = request.syntax.unwrap_or_else(generic_syntax); let mut classified = Vec::with_capacity(request.tests.len()); - for test in &request.tests { - let ctes = extract_ctes(&test.sql, &test.file_label)?; - classified.push(classify(ctes, &test.file_label, &test.mode)?); + for (index, test) in request.tests.iter().enumerate() { + match extract_test(test, &syntax) { + Ok(test) => classified.push(test), + Err(failure) => { + let (token, token_offset) = failure.token.unzip(); + return serde_json::to_string(&BatchResponse::Error(ErrorResponse { + index, + message: failure.message, + help: failure.help, + token, + token_offset, + })) + .map_err(|error| error.to_string()); + } + } } - serde_json::to_string(&classified).map_err(|error| error.to_string()) + serde_json::to_string(&BatchResponse::Tests(classified)).map_err(|error| error.to_string()) } -fn extract_ctes(sql: &str, file: &str) -> Result, String> { - extract_ctes_with_quoting(sql, file).map(|(ctes, _)| ctes) +/// Generic SQL lexical rules: `--` line comments and non-nested block comments. +pub(crate) fn generic_syntax() -> LexicalSyntax { + LexicalSyntax { + line_comment_prefixes: vec!["--".to_owned()], + ..LexicalSyntax::default() + } +} + +/// The test being classified: its file label, the adapter's rules, and whether it is unexpanded. +struct TestScope<'a> { + file: &'a str, + syntax: &'a LexicalSyntax, + raw: bool, +} + +fn extract_test(test: &TestRequest, syntax: &LexicalSyntax) -> Result { + if test.authored { + return authored_ctes(test, syntax); + } + let (ctes, _) = + extract_ctes_with_quoting(&test.sql, &test.file_label, syntax, NameRule::Plain)?; + let scope = TestScope { + file: &test.file_label, + syntax, + raw: test.raw, + }; + match test.mode.as_str() { + "model" => classify_model(ctes, &scope), + "macro" | "udf" | "table_fn" => classify_direct(ctes, &test.mode, &scope), + mode => Err(format!("SQL test '{}' has unsupported mode '{mode}'", scope.file).into()), + } +} + +/// The authored block's CTEs, or the located error of a CTE name; other errors read as no CTEs. +fn authored_ctes(test: &TestRequest, syntax: &LexicalSyntax) -> Result { + match scan_ctes(&test.sql, &test.file_label, syntax, NameRule::Plain) { + Ok(scanned) => Ok(Classified::Authored { + ctes: scanned + .ctes + .into_iter() + .zip(scanned.body_starts) + .map(|(cte, start)| (cte.0, start, cte.1)) + .collect(), + }), + Err(failure) if failure.token.is_some() => Err(failure), + Err(_) => Ok(Classified::Authored { ctes: Vec::new() }), + } } /// The top-level CTEs and whether any CTE name was a quoted identifier. -pub(crate) fn extract_ctes_with_quoting(sql: &str, file: &str) -> Result<(Vec, bool), String> { +pub(crate) fn extract_ctes_with_quoting( + sql: &str, + file: &str, + syntax: &LexicalSyntax, + rule: NameRule, +) -> Result<(Vec, bool), Failure> { + scan_ctes(sql, file, syntax, rule).map(|scanned| (scanned.ctes, scanned.quoted)) +} + +/// The top-level CTEs of one test, with the code-point offset of each stripped body. +struct ScannedCtes { + ctes: Vec, + quoted: bool, + body_starts: Vec, +} + +fn scan_ctes( + sql: &str, + file: &str, + syntax: &LexicalSyntax, + rule: NameRule, +) -> Result { + let mut body_starts: Vec = Vec::new(); let mut quoted = false; - let mut index = skip_ignorable(sql, 0)?; + let mut index = skip_ignorable(sql, 0, syntax)?; index = consume_keyword(sql, index, "WITH").ok_or_else(|| { format!("SQL test '{file}' must declare mock CTEs and one __expected__ CTE in a top-level WITH clause") })?; - index = skip_ignorable(sql, index)?; + index = skip_ignorable(sql, index, syntax)?; if let Some(end) = consume_keyword(sql, index, "RECURSIVE") { - index = skip_ignorable(sql, end)?; + index = skip_ignorable(sql, end, syntax)?; } let mut ctes: Vec = Vec::new(); let mut seen: HashSet = HashSet::new(); loop { quoted |= matches!(byte_at(sql, index), Some(b'"' | b'`')); - let (name, end) = read_identifier(sql, index) - .ok_or_else(|| format!("SQL test '{file}' expected a CTE name"))?; + let (name, end) = match rule { + NameRule::Plain => read_cte_name(sql, index, file, syntax)?, + NameRule::Lenient => read_identifier(sql, index, syntax) + .ok_or_else(|| format!("SQL test '{file}' expected a CTE name"))?, + }; if !seen.insert(name.clone()) { - return Err(format!("SQL test '{file}' defines duplicate CTE '{name}'")); + return Err(format!("SQL test '{file}' defines duplicate CTE '{name}'").into()); } - index = skip_ignorable(sql, end)?; + index = skip_ignorable(sql, end, syntax)?; if byte_at(sql, index) == Some(b'(') { - index = skip_ignorable(sql, matching_paren(sql, index, "SQL test")? + 1)?; + index = skip_ignorable( + sql, + matching_paren(sql, index, "SQL test", syntax)? + 1, + syntax, + )?; } index = consume_keyword(sql, index, "AS") .ok_or_else(|| format!("SQL test '{file}' expected keyword AS"))?; - index = skip_ignorable(sql, index)?; + index = skip_ignorable(sql, index, syntax)?; if byte_at(sql, index) != Some(b'(') { - return Err(format!("SQL test '{file}' CTE '{name}' must use AS (...)")); + return Err(format!("SQL test '{file}' CTE '{name}' must use AS (...)").into()); } - let close = matching_paren(sql, index, "SQL test")?; - ctes.push(Cte(name, sql[index + 1..close].trim().to_owned())); - index = skip_ignorable(sql, close + 1)?; + let close = matching_paren(sql, index, "SQL test", syntax)?; + let body = &sql[index + 1..close]; + let leading = body.len() - body.trim_start_matches(is_python_space).len(); + body_starts.push(sql[..index + 1 + leading].chars().count()); + ctes.push(Cte(name, python_strip(body).to_owned())); + index = skip_ignorable(sql, close + 1, syntax)?; if byte_at(sql, index) == Some(b',') { - index = skip_ignorable(sql, index + 1)?; + index = skip_ignorable(sql, index + 1, syntax)?; } else { break; } } - if !statement_ends(sql, index)? && !ceremonial_select_matches(sql, index)? { + if !statement_ends(sql, index, syntax)? && !ceremonial_select_matches(sql, index, syntax)? { return Err(format!( "SQL test '{file}' must end after its CTEs; only an optional ceremonial top-level `SELECT 1` may follow them" - )); + ).into()); } - Ok((ctes, quoted)) + Ok(ScannedCtes { + ctes, + quoted, + body_starts, + }) } -fn classify(ctes: Vec, file: &str, mode: &str) -> Result { - match mode { - "model" => classify_model(ctes, file), - "macro" | "udf" | "table_fn" => classify_direct(ctes, file, mode), - _ => Err(format!("SQL test '{file}' has unsupported mode '{mode}'")), +/// Read a top-level CTE name, which must be an unquoted ASCII identifier. +fn read_cte_name( + sql: &str, + start: usize, + file: &str, + syntax: &LexicalSyntax, +) -> Result<(String, usize), Failure> { + if let Some(quote) = byte_at(sql, start).filter(|byte| matches!(byte, b'"' | b'`' | b'[')) { + let end = if quote == b'[' { + sql[start..] + .find(']') + .map_or(sql.len(), |offset| start + offset + 1) + } else { + skip_non_code(sql, start, syntax)? + }; + return Err(cte_name_failure(sql, start, end, file)); } + let Some(first) = char_at(sql, start).filter(|first| *first == '_' || first.is_alphabetic()) + else { + return Err(format!("SQL test '{file}' expected a CTE name").into()); + }; + let mut end = start + first.len_utf8(); + while let Some(character) = + char_at(sql, end).filter(|character| is_identifier_continue(*character)) + { + end += character.len_utf8(); + } + if sql[start..end] + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') + { + return Ok((sql[start..end].to_owned(), end)); + } + Err(cte_name_failure(sql, start, end, file)) } -fn classify_model(ctes: Vec, file: &str) -> Result { - validate_independence(&ctes, file)?; +fn cte_name_failure(sql: &str, start: usize, end: usize, file: &str) -> Failure { + let token = &sql[start..end]; + let shown = if matches!(byte_at(sql, start), Some(b'"' | b'`' | b'[')) { + token.to_owned() + } else { + format!("'{token}'") + }; + let mut suggestion: String = token + .trim_matches(|character| matches!(character, '"' | '`' | '[' | ']')) + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || character == '_' { + character + } else { + '_' + } + }) + .collect(); + if !suggestion + .chars() + .next() + .is_some_and(|first| first.is_ascii_alphabetic() || first == '_') + { + suggestion.insert_str(0, "cte_"); + } + Failure { + message: format!( + "SQL test '{file}' CTE name {shown} must be an unquoted identifier of ASCII letters, digits and underscores" + ), + help: Some(format!( + "rename the CTE, for example {suggestion}; quoted CTE names and names with $ or non-ASCII characters are not supported" + )), + token: Some((token.to_owned(), sql[..start].chars().count())), + } +} + +fn classify_model(ctes: Vec, scope: &TestScope<'_>) -> Result { + let (file, syntax) = (scope.file, scope.syntax); + validate_independence(&ctes, file, syntax)?; let mut authored: Vec = Vec::new(); let mut macro_mocks: Vec<(String, String)> = Vec::new(); let mut mock_models: Vec = Vec::new(); @@ -180,14 +401,16 @@ fn classify_model(ctes: Vec, file: &str) -> Result { let name = cte.0.as_str(); for (direct_mode, actual, expected_name) in DIRECT_NAMES { if name == actual || name == expected_name { + let label = direct_test_label(direct_mode); return Err(format!( - "SQL test '{file}' is mode 'model' but defines {direct_mode}-test CTE '{name}'; use TEST (mode {direct_mode})" - )); + "SQL test '{file}' is mode 'model' but defines {label} CTE '{name}'; use TEST (mode {direct_mode})" + ) + .into()); } } if let Some(value) = name.strip_prefix("__macro__") { require_suffix(value, "__macro__", file)?; - macro_mocks.push((value.to_owned(), macro_mock_value(&cte, file)?)); + macro_mocks.push((value.to_owned(), macro_mock_value(&cte, file, syntax)?)); } else if let Some(value) = name.strip_prefix("__ref__") { mock_models.push(required(value, "__ref__", file)?); authored.push(cte); @@ -210,16 +433,16 @@ fn classify_model(ctes: Vec, file: &str) -> Result { } else if let Some(value) = name.strip_prefix("__expected__") { expected_models.push(required(value, "__expected__", file)?); let label = cte.0.clone(); - validate_expected(&cte, file, &label, true)?; + validate_expected(&cte, &label, true, scope)?; expected.push(cte); } else if let Some(value) = name.strip_prefix("__assert__") { assertion_names.push(required(value, "__assert__", file)?); assertions.push(cte); } else { if RESERVED_NAMES.contains(&name) { - return Err(format!( - "SQL test '{file}' uses reserved helper CTE name '{name}'" - )); + return Err( + format!("SQL test '{file}' uses reserved helper CTE name '{name}'").into(), + ); } authored.push(cte); } @@ -232,12 +455,14 @@ fn classify_model(ctes: Vec, file: &str) -> Result { { return Err(format!( "SQL test '{file}' must define at least one __ref__*, __source__*, __seed__*, __dbt_ref__*, or __table_fn__* mock CTE" - )); + ) + .into()); } if expected_models.is_empty() && assertion_names.is_empty() { return Err(format!( "SQL test '{file}' must define at least one __expected__ or __assert__ CTE" - )); + ) + .into()); } Ok(Classified::Model { mode: "model".to_owned(), @@ -255,7 +480,21 @@ fn classify_model(ctes: Vec, file: &str) -> Result { }) } -fn classify_direct(ctes: Vec, file: &str, mode: &str) -> Result { +/// The label error messages use for one direct-logic mode's CTEs. +fn direct_test_label(mode: &str) -> &'static str { + match mode { + MACRO_TEST_MODE => "macro-test", + UDF_TEST_MODE => "UDF-test", + _ => "table_fn-test", + } +} + +fn classify_direct( + ctes: Vec, + mode: &str, + scope: &TestScope<'_>, +) -> Result { + let file = scope.file; let (_, actual_name, expected_name) = DIRECT_NAMES .iter() .find(|entry| entry.0 == mode) @@ -267,49 +506,44 @@ fn classify_direct(ctes: Vec, file: &str, mode: &str) -> Result, file: &str, mode: &str) -> Result S struct LogicValidation<'a> { sql: &'a str, - file: &'a str, mode: &'a str, label: &'a str, + helper: bool, allowed: &'a str, + scope: &'a TestScope<'a>, } -fn validate_no_logic_calls(validation: LogicValidation<'_>) -> Result<(), String> { - if !macro_calls(validation.sql)?.is_empty() { +/// Whether a body outside the actual CTE has malformed reference calls; logic calls are errors. +fn malformed_calls_outside_actual(validation: &LogicValidation<'_>) -> Result { + let file = validation.scope.file; + let calls_macros = if validation.scope.raw { + calls_macro(validation.sql) + } else { + !macro_calls(validation.sql, validation.scope.syntax)?.is_empty() + }; + if calls_macros { + let suffix = if validation.helper && validation.mode == MACRO_TEST_MODE { + "; call macros only in __macro_actual__" + } else { + "" + }; return Err(format!( - "SQL test '{}' mode '{}' {} must not call macros", - validation.file, validation.mode, validation.label + "SQL test '{file}' mode '{}' {} must not call macros{suffix}", + validation.mode, validation.label )); } - for (needle, kind) in [("__udf(", "udf"), ("__table_fn(", "table_fn")] { - if contains_executable(validation.sql, needle)? { - return Err(format!( - "SQL test '{}' mode '{}' {} must not call {kind}; call reusable logic only in {}", - validation.file, validation.mode, validation.label, validation.allowed - )); - } + let calls = body_calls(validation.sql, validation.scope.syntax)?; + if let Some(kind) = calls.first_logic_kind { + return Err(format!( + "SQL test '{file}' mode '{}' {} must not call {kind}; call reusable logic only in {}", + validation.mode, validation.label, validation.allowed + )); } - Ok(()) + Ok(calls.invalid) } fn is_model_name(name: &str) -> bool { @@ -393,19 +643,19 @@ fn require_suffix(value: &str, label: &str, file: &str) -> Result<(), String> { } } -fn macro_mock_value(cte: &Cte, file: &str) -> Result { +fn macro_mock_value(cte: &Cte, file: &str, syntax: &LexicalSyntax) -> Result { let body = cte.1.as_str(); - let mut index = skip_ignorable(body, 0)?; + let mut index = skip_ignorable(body, 0, syntax)?; index = consume_keyword(body, index, "SELECT") .ok_or_else(|| macro_mock_shape(file, &cte.0, false))?; - index = skip_ignorable(body, index)?; + index = skip_ignorable(body, index, syntax)?; if byte_at(body, index) != Some(b'\'') { return Err(macro_mock_shape(file, &cte.0, false)); } let (value, end) = read_string(body, index)?; - index = skip_ignorable(body, end)?; + index = skip_ignorable(body, end, syntax)?; if byte_at(body, index) == Some(b';') { - index = skip_ignorable(body, index + 1)?; + index = skip_ignorable(body, index + 1, syntax)?; } if index != body.len() { return Err(macro_mock_shape(file, &cte.0, true)); @@ -427,22 +677,21 @@ fn macro_mock_shape(file: &str, name: &str, trailing: bool) -> String { fn validate_expected( cte: &Cte, - file: &str, label: &str, allow_empty_fixture: bool, -) -> Result<(), String> { - if allow_empty_fixture && empty_fixture_marker_matches(&cte.1)? { + scope: &TestScope<'_>, +) -> Result<(), Failure> { + let (file, syntax) = (scope.file, scope.syntax); + if allow_empty_fixture && empty_fixture_marker_matches(&cte.1, syntax)? { return Ok(()); } - if contains_select_star(&cte.1)? { - return Err(format!( - "SQL test '{file}' must not use SELECT * in {label} CTEs" - )); + if contains_select_star(&cte.1, syntax)? { + return Err(select_star_error(file, label).into()); } - let branches = split_set_operations(&cte.1)?; + let branches = split_set_operations(&cte.1, syntax)?; let mut names: Vec> = Vec::new(); for branch in branches { - names.push(projection_names(branch, file, label)?); + names.push(projection_names(branch, file, label, syntax)?); } if let Some(first) = names.first() { for (index, branch) in names.iter().enumerate().skip(1) { @@ -450,132 +699,265 @@ fn validate_expected( return Err(format!( "SQL test '{file}' must use the same {label} projection names and order in every set-operation branch; branch {} does not match branch 1", index + 1 - )); + ) + .into()); } } } Ok(()) } -pub(crate) fn empty_fixture_marker_matches(sql: &str) -> Result { - let mut index = skip_ignorable(sql, 0)?; +fn select_star_error(file: &str, label: &str) -> String { + format!("SQL test '{file}' must not use SELECT * in {label} CTEs") +} + +pub(crate) fn empty_fixture_marker_matches( + sql: &str, + syntax: &LexicalSyntax, +) -> Result { + let mut index = skip_ignorable(sql, 0, syntax)?; let Some(select_end) = consume_keyword(sql, index, "SELECT") else { return Ok(false); }; - index = skip_ignorable(sql, select_end)?; + index = skip_ignorable(sql, select_end, syntax)?; if byte_at(sql, index) != Some(b'*') { return Ok(false); } - index = skip_ignorable(sql, index + 1)?; + index = skip_ignorable(sql, index + 1, syntax)?; let Some(from_end) = consume_keyword(sql, index, "FROM") else { return Ok(false); }; - index = skip_ignorable(sql, from_end)?; - let Some((name, name_end)) = read_identifier(sql, index) else { + index = skip_ignorable(sql, from_end, syntax)?; + let Some((name, name_end)) = read_identifier(sql, index, syntax) else { return Ok(false); }; if !name.eq_ignore_ascii_case("__empty_fixture") { return Ok(false); } - index = skip_ignorable(sql, name_end)?; + index = skip_ignorable(sql, name_end, syntax)?; if byte_at(sql, index) != Some(b'(') { return Ok(false); } - index = skip_ignorable(sql, index + 1)?; + index = skip_ignorable(sql, index + 1, syntax)?; if byte_at(sql, index) != Some(b')') { return Ok(false); } - index = skip_ignorable(sql, index + 1)?; + index = skip_ignorable(sql, index + 1, syntax)?; Ok(index == sql.len()) } -fn projection_names(branch: &str, file: &str, label: &str) -> Result, String> { - let start = skip_ignorable(branch, 0)?; - let select_end = consume_keyword(branch, start, "SELECT").ok_or_else(|| { +fn projection_names( + branch: &str, + file: &str, + label: &str, + syntax: &LexicalSyntax, +) -> Result, Failure> { + let start = skip_ignorable(branch, 0, syntax)?; + if byte_at(branch, start) == Some(b'(') { + return Err(format!( + "SQL test '{file}' must write each {label} set-operation branch as a plain SELECT without enclosing parentheses" + ) + .into()); + } + if consume_keyword(branch, start, "WITH").is_some() { + return Err(Failure { + message: format!("SQL test '{file}' must not use WITH inside {label}"), + help: Some( + "define the shared rows as a helper CTE in the test's top-level WITH clause and select from it" + .to_owned(), + ), + token: None, + }); + } + let mut select_end = consume_keyword(branch, start, "SELECT").ok_or_else(|| { format!("SQL test '{file}' must define each {label} set-operation branch as a SELECT query") })?; - let mut end = branch.len(); + let quantifier = skip_ignorable(branch, select_end, syntax)?; + if let Some(end) = consume_keyword(branch, quantifier, "DISTINCT") + .or_else(|| consume_keyword(branch, quantifier, "ALL")) + { + select_end = end; + let on = skip_ignorable(branch, end, syntax)?; + if let Some(on_end) = consume_keyword(branch, on, "ON") { + let open = skip_ignorable(branch, on_end, syntax)?; + if byte_at(branch, open) == Some(b'(') { + select_end = matching_paren(branch, open, "SQL test", syntax)? + 1; + } + } + } + let mut end = select_list_from(branch, select_end, syntax)?.unwrap_or(branch.len()); for keyword in [ - "FROM", "WHERE", "GROUP", "HAVING", "QUALIFY", "WINDOW", "ORDER", "LIMIT", "OFFSET", - "FETCH", + "WHERE", "GROUP", "HAVING", "QUALIFY", "WINDOW", "ORDER", "LIMIT", "OFFSET", "FETCH", ] { - if let Some(position) = find_top_level_clause_keyword(branch, select_end, keyword)? { + if let Some(position) = find_top_level_clause_keyword(branch, select_end, keyword, syntax)? + { end = end.min(position); } } - let expressions = split_top_level(&branch[select_end..end], b',')?; + let expressions = split_top_level(&branch[select_end..end], b',', syntax)?; if expressions.is_empty() { - return Err(format!( - "SQL test '{file}' must project at least one column in {label}" - )); + return Err( + format!("SQL test '{file}' must project at least one column in {label}").into(), + ); } expressions .into_iter() - .map(|expression| projection_name(expression, file, label)) + .map(|expression| projection_name(expression, file, label, syntax)) .collect() } -fn projection_name(expression: &str, file: &str, label: &str) -> Result { - if let Some(position) = find_last_top_level_keyword(expression, "AS")? { - let alias_start = skip_ignorable(expression, position + 2)?; - if let Some((alias, end)) = read_identifier(expression, alias_start) - && skip_ignorable(expression, end)? == expression.len() +/// An `AS` alias, or the name of a bare or qualified column reference; anything else needs `AS`. +fn projection_name( + expression: &str, + file: &str, + label: &str, + syntax: &LexicalSyntax, +) -> Result { + if let Some(position) = find_last_top_level_keyword(expression, "AS", syntax)? { + let alias_start = skip_ignorable(expression, position + 2, syntax)?; + if let Some((alias, end)) = read_identifier(expression, alias_start, syntax) + && skip_ignorable(expression, end, syntax)? == expression.len() { return Ok(alias); } } - let value = expression.trim(); - if read_identifier(value, 0).is_some_and(|(_, end)| end == value.len()) { + let value = python_strip(expression); + if value == SELECT_STAR_PROJECTION + || value + .strip_suffix(SELECT_STAR_PROJECTION) + .is_some_and(|qualifier| qualifier.trim_end().ends_with('.')) + { + return Err(select_star_error(file, label).into()); + } + if read_identifier(value, 0, syntax).is_some_and(|(_, end)| end == value.len()) { return Ok(value.to_owned()); } - if let Some(name) = qualified_projection_name(value)? { + if let Some(name) = qualified_projection_name(value, syntax)? { return Ok(name); } - if let Some(alias) = implicit_projection_alias(value) { + if let Some(alias) = implicit_alias(value, syntax)? { return Ok(alias); } - Err(format!( - "SQL test '{file}' must alias every non-trivial {label} projection" - )) + let shown = if value.chars().count() <= ALIAS_HELP_EXPRESSION_LIMIT && !value.contains('\n') { + value + } else { + "" + }; + Err(Failure { + message: format!("SQL test '{file}' must alias every non-trivial {label} projection"), + help: Some(format!( + "name the column with an alias, for example {shown} AS ; only a bare or qualified column reference may go unaliased" + )), + token: None, + }) +} + +/// The implicit alias ending `expression`, unless that last token is an operand or a value keyword. +fn implicit_alias(expression: &str, syntax: &LexicalSyntax) -> Result, String> { + let Some(alias_start) = last_top_level_token_start(expression, syntax)? else { + return Ok(None); + }; + let Some((alias, alias_end)) = read_identifier(expression, alias_start, syntax) else { + return Ok(None); + }; + if skip_ignorable(expression, alias_end, syntax)? != expression.len() { + return Ok(None); + } + let quoted = matches!(byte_at(expression, alias_start), Some(b'"' | b'`')); + if !quoted && is_keyword(&alias, VALUE_KEYWORDS.iter().chain(OPERAND_KEYWORDS)) { + return Ok(None); + } + let Some(prefix) = non_empty_trimmed(&expression[..alias_start], syntax)? else { + return Ok(None); + }; + if prefix + .chars() + .next_back() + .is_some_and(|character| OPERATOR_CHARACTERS.contains(character)) + { + return Ok(None); + } + let word_start = prefix + .char_indices() + .rev() + .take_while(|(_, character)| is_identifier_continue(*character)) + .last() + .map_or(prefix.len(), |(index, _)| index); + Ok((!is_keyword(&prefix[word_start..], OPERAND_KEYWORDS.iter())).then_some(alias)) +} + +fn is_keyword<'a>(word: &str, mut keywords: impl Iterator) -> bool { + keywords.any(|keyword| keyword.eq_ignore_ascii_case(word)) } -fn qualified_projection_name(expression: &str) -> Result, String> { - let Some((mut name, mut end)) = read_identifier(expression, 0) else { +/// The start of the last top-level token that follows whitespace or a comment. +fn last_top_level_token_start( + expression: &str, + syntax: &LexicalSyntax, +) -> Result, String> { + let mut last = None; + let mut separated = false; + let mut depth = 0_isize; + let mut index = 0; + while index < expression.len() { + if let Some(end) = comment_end(expression, index, syntax)? { + separated = true; + index = end; + continue; + } + if char_at(expression, index).is_some_and(is_python_space) { + separated = true; + index += char_len(expression, index); + continue; + } + if separated && depth == 0 { + last = Some(index); + } + separated = false; + let next = skip_non_code(expression, index, syntax)?; + if next != index { + index = next; + continue; + } + match byte_at(expression, index) { + Some(b'(') => depth += 1, + Some(b')') => depth -= 1, + _ => {} + } + index += char_len(expression, index); + } + Ok(last) +} + +fn qualified_projection_name( + expression: &str, + syntax: &LexicalSyntax, +) -> Result, String> { + let Some((mut name, mut end)) = read_identifier(expression, 0, syntax) else { return Ok(None); }; let mut qualified = false; loop { - end = skip_ignorable(expression, end)?; + end = skip_ignorable(expression, end, syntax)?; if byte_at(expression, end) != Some(b'.') { break; } - let part_start = skip_ignorable(expression, end + 1)?; - let Some((part, part_end)) = read_identifier(expression, part_start) else { + let part_start = skip_ignorable(expression, end + 1, syntax)?; + let Some((part, part_end)) = read_identifier(expression, part_start, syntax) else { return Ok(None); }; name = part; end = part_end; qualified = true; } - Ok((qualified && expression[end..].trim().is_empty()).then_some(name)) -} - -fn implicit_projection_alias(expression: &str) -> Option { - let alias_start = expression.rfind(char::is_whitespace)? + 1; - let prefix = expression[..alias_start].trim_end(); - if prefix.is_empty() - || prefix - .chars() - .next_back() - .is_some_and(|character| "+-*/%=<>|&^,".contains(character)) - { - return None; - } - let (alias, end) = read_identifier(expression, alias_start)?; - (end == expression.len()).then_some(alias) + Ok((qualified && python_strip(&expression[end..]).is_empty()).then_some(name)) } -pub(crate) fn validate_independence(ctes: &[Cte], file: &str) -> Result<(), String> { +pub(crate) fn validate_independence( + ctes: &[Cte], + file: &str, + syntax: &LexicalSyntax, +) -> Result<(), String> { let names: HashMap = ctes .iter() .map(|cte| (cte.0.to_lowercase(), cte.0.clone())) @@ -599,7 +981,7 @@ pub(crate) fn validate_independence(ctes: &[Cte], file: &str) -> Result<(), Stri continue; }; if cte.1.to_lowercase().contains(prohibited_prefix) - && let Some(nested) = nested_cte_names(&cte.1)? + && let Some(nested) = nested_cte_names(&cte.1, syntax)? .into_iter() .find(|name| name.starts_with(prohibited_prefix)) { @@ -615,7 +997,7 @@ pub(crate) fn validate_independence(ctes: &[Cte], file: &str) -> Result<(), Stri let dependencies: HashMap> = ctes .iter() .map(|cte| { - let refs = known_relation_names(&cte.1, &names) + let refs = known_relation_names(&cte.1, &names, syntax) .unwrap_or_else(|_| known_identifiers(&cte.1, &names)); (cte.0.to_lowercase(), refs) }) @@ -680,50 +1062,62 @@ fn dependency_path( None } -fn nested_cte_names(sql: &str) -> Result, String> { +fn nested_cte_names(sql: &str, syntax: &LexicalSyntax) -> Result, String> { let mut names: Vec = Vec::new(); let mut index = 0; - while let Some(position) = find_keyword(sql, index, "WITH")? { - let mut cursor = skip_ignorable(sql, position + 4)?; - while let Some((name, end)) = read_identifier(sql, cursor) { + while let Some(position) = find_keyword(sql, index, "WITH", syntax)? { + let mut cursor = skip_ignorable(sql, position + 4, syntax)?; + while let Some((name, end)) = read_identifier(sql, cursor, syntax) { if !names.contains(&name) { names.push(name); } - cursor = skip_ignorable(sql, end)?; + cursor = skip_ignorable(sql, end, syntax)?; if byte_at(sql, cursor) == Some(b'(') { - cursor = skip_ignorable(sql, matching_paren(sql, cursor, "SQL test")? + 1)?; + cursor = skip_ignorable( + sql, + matching_paren(sql, cursor, "SQL test", syntax)? + 1, + syntax, + )?; } let Some(as_end) = consume_keyword(sql, cursor, "AS") else { break; }; - cursor = skip_ignorable(sql, as_end)?; + cursor = skip_ignorable(sql, as_end, syntax)?; if byte_at(sql, cursor) != Some(b'(') { break; } - cursor = skip_ignorable(sql, matching_paren(sql, cursor, "SQL test")? + 1)?; + cursor = skip_ignorable( + sql, + matching_paren(sql, cursor, "SQL test", syntax)? + 1, + syntax, + )?; if byte_at(sql, cursor) != Some(b',') { break; } - cursor = skip_ignorable(sql, cursor + 1)?; + cursor = skip_ignorable(sql, cursor + 1, syntax)?; } index = position + 4; } Ok(names) } -fn known_relation_names(sql: &str, names: &HashMap) -> Result, String> { +fn known_relation_names( + sql: &str, + names: &HashMap, + syntax: &LexicalSyntax, +) -> Result, String> { let mut refs: Vec = Vec::new(); let mut index = 0; let mut depth = 0_usize; let mut from_depths: HashSet = HashSet::new(); let mut expected_relation_depths: HashSet = HashSet::new(); while index < sql.len() { - index = skip_ignorable(sql, index)?; + index = skip_ignorable(sql, index, syntax)?; if index >= sql.len() { break; } if expected_relation_depths.remove(&depth) - && let Some((name, end)) = read_identifier(sql, index) + && let Some((name, end)) = read_identifier(sql, index, syntax) { let key = name.to_lowercase(); if names.contains_key(&key) && !refs.contains(&key) { @@ -733,7 +1127,7 @@ fn known_relation_names(sql: &str, names: &HashMap) -> Result) -> Vec .collect() } -fn macro_calls(sql: &str) -> Result, String> { +fn macro_calls(sql: &str, syntax: &LexicalSyntax) -> Result, String> { let mut calls: Vec = Vec::new(); let mut index = 0; while index < sql.len() { - index = skip_non_code(sql, index)?; + index = skip_non_code(sql, index, syntax)?; if index >= sql.len() { break; } if byte_at(sql, index) == Some(b'@') - && let Some((name, end)) = read_identifier(sql, index + 1) + && let Some((name, end)) = read_identifier(sql, index + 1, syntax) { - let open = skip_whitespace(sql, end); + let open = space_end(sql, end); if byte_at(sql, open) == Some(b'(') && !DECLARATION_CALLS.contains(&name.as_str()) { if !calls.contains(&name) { calls.push(name); } - index = matching_paren(sql, open, "SQL macro")? + 1; + index = matching_paren(sql, open, "SQL macro", syntax)? + 1; continue; } } @@ -825,25 +1219,16 @@ fn macro_calls(sql: &str) -> Result, String> { Ok(calls) } -fn contains_executable(sql: &str, needle: &str) -> Result { +/// Whether any `SELECT`, optionally `DISTINCT` or `ALL`, is followed by `*`. +fn contains_select_star(sql: &str, syntax: &LexicalSyntax) -> Result { let mut index = 0; - while index < sql.len() { - index = skip_non_code(sql, index)?; - if index >= sql.len() { - break; - } - if sql[index..].starts_with(needle) { - return Ok(true); + while let Some(position) = find_keyword(sql, index, "SELECT", syntax)? { + let mut value = skip_ignorable(sql, position + 6, syntax)?; + if let Some(end) = + consume_keyword(sql, value, "DISTINCT").or_else(|| consume_keyword(sql, value, "ALL")) + { + value = skip_ignorable(sql, end, syntax)?; } - index += char_len(sql, index); - } - Ok(false) -} - -fn contains_select_star(sql: &str) -> Result { - let mut index = 0; - while let Some(position) = find_keyword(sql, index, "SELECT")? { - let value = skip_ignorable(sql, position + 6)?; if byte_at(sql, value) == Some(b'*') { return Ok(true); } @@ -863,12 +1248,13 @@ enum CodeStep { fn scan_code( sql: &str, start: usize, + syntax: &LexicalSyntax, mut visit: impl FnMut(usize, isize) -> Result, String>, ) -> Result, String> { let mut index = start; let mut depth = 0; while index < sql.len() { - let next = skip_non_code(sql, index)?; + let next = skip_non_code(sql, index, syntax)?; if next != index { index = next; continue; @@ -888,28 +1274,26 @@ fn scan_code( } /// Return `value` without surrounding whitespace and comments, or `None` when no code remains. -fn non_empty_trimmed(value: &str) -> Result, String> { - if !value.contains("--") && !value.contains("/*") { - return Ok(Some(value.trim()).filter(|trimmed| !trimmed.is_empty())); - } - let start = skip_ignorable(value, 0)?; +fn non_empty_trimmed<'a>( + value: &'a str, + syntax: &LexicalSyntax, +) -> Result, String> { + let start = skip_ignorable(value, 0, syntax)?; let mut end = start; let mut index = start; while index < value.len() { - if let Some(comment) = comment_end(value.as_bytes(), index) - .map_err(|error| scan_error_message(error, "SQL test"))? - { + if let Some(comment) = comment_end(value, index, syntax)? { index = comment; continue; } - let next = skip_non_code(value, index)?; + let next = skip_non_code(value, index, syntax)?; if next != index { end = next; index = next; continue; } let width = char_len(value, index); - if !value[index..].starts_with(char::is_whitespace) { + if !char_at(value, index).is_some_and(is_python_space) { end = index + width; } index += width; @@ -918,54 +1302,71 @@ fn non_empty_trimmed(value: &str) -> Result, String> { } /// Split on top-level `UNION`, `INTERSECT` and `EXCEPT`, each with an optional `ALL`/`DISTINCT`. -pub(crate) fn split_set_operations(sql: &str) -> Result, String> { +pub(crate) fn split_set_operations<'a>( + sql: &'a str, + syntax: &LexicalSyntax, +) -> Result, String> { let mut values: Vec<&str> = Vec::new(); let mut start = 0; - scan_code::<()>(sql, 0, |index, depth| { + scan_code::<()>(sql, 0, syntax, |index, depth| { if depth != 0 { return Ok(CodeStep::Advance); } - let Some(end) = set_operator_end(sql, index)? else { + let Some(end) = set_operator_end(sql, index, syntax)? else { return Ok(CodeStep::Advance); }; - values.extend(non_empty_trimmed(&sql[start..index])?); - let mut resume = skip_ignorable(sql, end)?; + values.extend(non_empty_trimmed(&sql[start..index], syntax)?); + let mut resume = skip_ignorable(sql, end, syntax)?; if let Some(quantifier_end) = consume_keyword(sql, resume, "ALL").or_else(|| consume_keyword(sql, resume, "DISTINCT")) { - resume = skip_ignorable(sql, quantifier_end)?; + resume = skip_ignorable(sql, quantifier_end, syntax)?; } start = resume; Ok(CodeStep::Resume(resume)) })?; - values.extend(non_empty_trimmed(&sql[start..])?); + values.extend(non_empty_trimmed(&sql[start..], syntax)?); Ok(values) } /// Return the end of a set operator at `index`; `* EXCEPT (...)` star modifiers are not operators. -fn set_operator_end(sql: &str, index: usize) -> Result, String> { +fn set_operator_end( + sql: &str, + index: usize, + syntax: &LexicalSyntax, +) -> Result, String> { if let Some(end) = consume_keyword(sql, index, "UNION").or_else(|| consume_keyword(sql, index, "INTERSECT")) { return Ok(Some(end)); } match consume_keyword(sql, index, "EXCEPT") { - Some(end) if previous_code_byte(sql, index)? != Some(b'*') => Ok(Some(end)), + Some(end) if previous_code_byte(sql, index, syntax)? != Some(b'*') => Ok(Some(end)), _ => Ok(None), } } -pub(crate) fn split_top_level(sql: &str, separator: u8) -> Result, String> { +pub(crate) fn split_top_level<'a>( + sql: &'a str, + separator: u8, + syntax: &LexicalSyntax, +) -> Result, String> { let mut values: Vec<&str> = Vec::new(); let mut start = 0; - scan_code::<()>(sql, 0, |index, depth| { - if depth == 0 && byte_at(sql, index) == Some(separator) { - values.extend(non_empty_trimmed(&sql[start..index])?); - start = index + 1; + let mut bracket_depth: isize = 0; + scan_code::<()>(sql, 0, syntax, |index, depth| { + match byte_at(sql, index) { + Some(b'[' | b'{') => bracket_depth += 1, + Some(b']' | b'}') => bracket_depth -= 1, + Some(byte) if byte == separator && depth == 0 && bracket_depth == 0 => { + values.extend(non_empty_trimmed(&sql[start..index], syntax)?); + start = index + 1; + } + _ => {} } Ok(CodeStep::Advance) })?; - values.extend(non_empty_trimmed(&sql[start..])?); + values.extend(non_empty_trimmed(&sql[start..], syntax)?); Ok(values) } @@ -973,8 +1374,9 @@ pub(crate) fn find_top_level_keyword( sql: &str, start: usize, keyword: &str, + syntax: &LexicalSyntax, ) -> Result, String> { - scan_code(sql, start, |index, depth| { + scan_code(sql, start, syntax, |index, depth| { Ok( if depth == 0 && consume_keyword(sql, index, keyword).is_some() { CodeStep::Stop(index) @@ -985,14 +1387,73 @@ pub(crate) fn find_top_level_keyword( }) } +/// The top-level `FROM` that ends a select list, skipping the `FROM` of `IS [NOT] DISTINCT FROM`. +fn select_list_from( + sql: &str, + start: usize, + syntax: &LexicalSyntax, +) -> Result, String> { + let mut search_start = start; + while let Some(position) = find_top_level_clause_keyword(sql, search_start, "FROM", syntax)? { + if !trailing_distinct_operator(&sql[start..position], syntax)? { + return Ok(Some(position)); + } + search_start = position + "FROM".len(); + } + Ok(None) +} + +/// Whether `prefix` ends with `IS DISTINCT` or `IS NOT DISTINCT`, ignoring comments. +fn trailing_distinct_operator(prefix: &str, syntax: &LexicalSyntax) -> Result { + let Some((rest, word)) = trailing_word(prefix, syntax)? else { + return Ok(false); + }; + if !word.eq_ignore_ascii_case("DISTINCT") { + return Ok(false); + } + let Some((rest, word)) = trailing_word(rest, syntax)? else { + return Ok(false); + }; + if word.eq_ignore_ascii_case("IS") { + return Ok(true); + } + if !word.eq_ignore_ascii_case("NOT") { + return Ok(false); + } + let Some((_, word)) = trailing_word(rest, syntax)? else { + return Ok(false); + }; + Ok(word.eq_ignore_ascii_case("IS")) +} + +/// The code before the last word of `value` and that word, ignoring trailing comments. +fn trailing_word<'a>( + value: &'a str, + syntax: &LexicalSyntax, +) -> Result, String> { + let Some(trimmed) = non_empty_trimmed(value, syntax)? else { + return Ok(None); + }; + let trimmed_end = trimmed.as_ptr() as usize - value.as_ptr() as usize + trimmed.len(); + let code = &value[..trimmed_end]; + let word_start = code + .char_indices() + .rev() + .take_while(|(_, character)| is_identifier_continue(*character)) + .last() + .map(|(index, _)| index); + Ok(word_start.map(|start| (&code[..start], &code[start..]))) +} + fn find_top_level_clause_keyword( sql: &str, start: usize, keyword: &str, + syntax: &LexicalSyntax, ) -> Result, String> { let mut search_start = start; - while let Some(position) = find_top_level_keyword(sql, search_start, keyword)? { - if previous_code_byte(sql, position)? != Some(b'.') { + while let Some(position) = find_top_level_keyword(sql, search_start, keyword, syntax)? { + if previous_code_byte(sql, position, syntax)? != Some(b'.') { return Ok(Some(position)); } search_start = position + keyword.len(); @@ -1000,11 +1461,11 @@ fn find_top_level_clause_keyword( Ok(None) } -fn previous_code_byte(sql: &str, end: usize) -> Result, String> { +fn previous_code_byte(sql: &str, end: usize, syntax: &LexicalSyntax) -> Result, String> { let mut previous = None; let mut index = 0; while index < end { - let next = skip_non_code(sql, index)?; + let next = skip_non_code(sql, index, syntax)?; if next != index { index = next; continue; @@ -1018,19 +1479,29 @@ fn previous_code_byte(sql: &str, end: usize) -> Result, String> { Ok(previous) } -fn find_last_top_level_keyword(sql: &str, keyword: &str) -> Result, String> { +fn find_last_top_level_keyword( + sql: &str, + keyword: &str, + syntax: &LexicalSyntax, +) -> Result, String> { let mut found = None; let mut start = 0; - while let Some(position) = find_top_level_keyword(sql, start, keyword)? { + while let Some(position) = find_top_level_keyword(sql, start, keyword, syntax)? { found = Some(position); start = position + keyword.len(); } Ok(found) } -fn find_keyword(sql: &str, start: usize, keyword: &str) -> Result, String> { + +fn find_keyword( + sql: &str, + start: usize, + keyword: &str, + syntax: &LexicalSyntax, +) -> Result, String> { let mut index = start; while index < sql.len() { - let next = skip_non_code(sql, index)?; + let next = skip_non_code(sql, index, syntax)?; if next != index { index = next; continue; @@ -1043,54 +1514,64 @@ fn find_keyword(sql: &str, start: usize, keyword: &str) -> Result, Ok(None) } -fn ceremonial_select_matches(sql: &str, start: usize) -> Result { - let mut index = skip_ignorable(sql, start)?; +fn ceremonial_select_matches( + sql: &str, + start: usize, + syntax: &LexicalSyntax, +) -> Result { + let mut index = skip_ignorable(sql, start, syntax)?; let Some(end) = consume_keyword(sql, index, "SELECT") else { return Ok(false); }; - index = skip_ignorable(sql, end)?; + index = skip_ignorable(sql, end, syntax)?; if byte_at(sql, index) != Some(b'1') { return Ok(false); } - statement_ends(sql, index + 1) + statement_ends(sql, index + 1, syntax) } -fn statement_ends(sql: &str, start: usize) -> Result { - let mut index = skip_ignorable(sql, start)?; +fn statement_ends(sql: &str, start: usize, syntax: &LexicalSyntax) -> Result { + let mut index = skip_ignorable(sql, start, syntax)?; if byte_at(sql, index) == Some(b';') { - index = skip_ignorable(sql, index + 1)?; + index = skip_ignorable(sql, index + 1, syntax)?; } Ok(index == sql.len()) } +/// Match `keyword` ASCII case-insensitively at `start`, outside a longer identifier. fn consume_keyword(sql: &str, start: usize, keyword: &str) -> Option { let end = start + keyword.len(); let value = sql.get(start..end)?; if !value.eq_ignore_ascii_case(keyword) { return None; } - if end < sql.len() && is_identifier_continue(sql[end..].chars().next()?) { + if char_at(sql, end).is_some_and(is_identifier_continue) { return None; } - if start > 0 && is_identifier_continue(sql[..start].chars().next_back()?) { + if sql[..start] + .chars() + .next_back() + .is_some_and(is_identifier_continue) + { return None; } Some(end) } -fn read_identifier(sql: &str, start: usize) -> Option<(String, usize)> { - if matches!(byte_at(sql, start), Some(b'"') | Some(b'`')) { - let quote = byte_at(sql, start)?; - let end = match skip_quote(sql, start, "SQL identifier") { - Ok(end) => end, - Err(_) => return None, + +/// Read an unquoted identifier, or a quoted one with doubled quotes unescaped. +fn read_identifier(sql: &str, start: usize, syntax: &LexicalSyntax) -> Option<(String, usize)> { + if let Some(quote) = byte_at(sql, start).filter(|byte| matches!(byte, b'"' | b'`')) { + let Ok(end) = skip_non_code(sql, start, syntax) else { + return None; }; let quote_text = char::from(quote).to_string(); return Some(( - sql[start + 1..end - 1].replace(&format!("{quote_text}{quote_text}"), "e_text), + sql.get(start + 1..end - 1)? + .replace(&format!("{quote_text}{quote_text}"), "e_text), end, )); } - let first = sql.get(start..)?.chars().next()?; + let first = char_at(sql, start)?; if !is_identifier_start(first) { return None; } @@ -1103,46 +1584,77 @@ fn read_identifier(sql: &str, start: usize) -> Option<(String, usize)> { } Some((sql[start..end].to_owned(), end)) } + fn is_identifier_start(value: char) -> bool { value == '_' || value.is_alphabetic() } + fn is_identifier_continue(value: char) -> bool { is_identifier_start(value) || value.is_numeric() || value == '$' } + fn byte_at(sql: &str, index: usize) -> Option { sql.as_bytes().get(index).copied() } + +fn char_at(sql: &str, index: usize) -> Option { + sql.get(index..).and_then(|rest| rest.chars().next()) +} + fn char_len(sql: &str, index: usize) -> usize { - sql[index..].chars().next().map_or(1, char::len_utf8) + char_at(sql, index).map_or(1, char::len_utf8) } -fn skip_ignorable(sql: &str, mut index: usize) -> Result { + +/// Skip Python whitespace (`str.isspace`). +fn space_end(sql: &str, mut index: usize) -> usize { + while let Some(character) = char_at(sql, index).filter(|character| is_python_space(*character)) + { + index += character.len_utf8(); + } + index +} + +/// The end of a comment starting at `index` under the adapter's rules; quoted text is code here. +fn comment_end(sql: &str, index: usize, syntax: &LexicalSyntax) -> Result, String> { + let rest = &sql.as_bytes()[index.min(sql.len())..]; + let starts_comment = rest.starts_with(b"/*") + || syntax + .line_comment_prefixes + .iter() + .any(|prefix| rest.starts_with(prefix.as_bytes())); + if !starts_comment { + return Ok(None); + } + dialect_non_code_end(sql.as_bytes(), index, syntax) + .map_err(|error| scan_error_message(error, "SQL test")) +} + +fn skip_ignorable(sql: &str, mut index: usize, syntax: &LexicalSyntax) -> Result { loop { - index = skip_whitespace(sql, index); - match comment_end(sql.as_bytes(), index) - .map_err(|error| scan_error_message(error, "SQL test"))? - { + index = space_end(sql, index); + match comment_end(sql, index, syntax)? { Some(end) => index = end, None => return Ok(index), } } } -fn skip_non_code(sql: &str, index: usize) -> Result { - Ok(non_code_end(sql.as_bytes(), index, QuotePolicy::COMPILER) +fn skip_non_code(sql: &str, index: usize, syntax: &LexicalSyntax) -> Result { + if index >= sql.len() { + return Ok(index); + } + Ok(dialect_non_code_end(sql.as_bytes(), index, syntax) .map_err(|error| scan_error_message(error, "SQL test"))? .unwrap_or(index)) } -fn skip_quote(sql: &str, start: usize, context: &str) -> Result { - if byte_at(sql, start).is_none() { - return Err(format!("{context} expected a quote")); - } - quote_end(sql.as_bytes(), start, QuotePolicy::COMPILER) - .map_err(|error| scan_error_message(error, context)) -} - -fn matching_paren(sql: &str, open: usize, context: &str) -> Result { - scan_matching_paren(sql.as_bytes(), open, QuotePolicy::COMPILER).map_err(|error| match error { +fn matching_paren( + sql: &str, + open: usize, + context: &str, + syntax: &LexicalSyntax, +) -> Result { + dialect_matching_paren(sql.as_bytes(), open, syntax).map_err(|error| match error { Unclosed::Parenthesis => scan_error_message(error, context), _ => scan_error_message(error, "SQL test"), }) @@ -1168,7 +1680,7 @@ fn read_string(sql: &str, start: usize) -> Result<(String, usize), String> { return Ok((value, index + 1)); } } else { - let Some(c) = sql[index..].chars().next() else { + let Some(c) = char_at(sql, index) else { break; }; value.push(c); 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 b4273df693..de20c4f5ee 100644 --- a/crates/sqlbuild-analysis/src/compiler/_helpers/sql_tests/mod.rs +++ b/crates/sqlbuild-analysis/src/compiler/_helpers/sql_tests/mod.rs @@ -1,3 +1,4 @@ +pub mod authored_macro_calls; pub mod cte_rename; pub mod cte_slices; pub mod cte_sql; @@ -8,6 +9,7 @@ pub mod helper_scope; pub mod markers; pub mod planning; pub mod reader_references; +pub mod reference_calls; pub mod relation_markers; pub mod rendering; pub mod scenario_extraction; diff --git a/crates/sqlbuild-analysis/src/compiler/_helpers/sql_tests/reference_calls.rs b/crates/sqlbuild-analysis/src/compiler/_helpers/sql_tests/reference_calls.rs new file mode 100644 index 0000000000..97b790d705 --- /dev/null +++ b/crates/sqlbuild-analysis/src/compiler/_helpers/sql_tests/reference_calls.rs @@ -0,0 +1,129 @@ +//! Reference calls in direct-logic helper and expected bodies, read as Python's reference scan. + +use crate::constants::{DBT_REF_REFERENCE_KIND, TABLE_FUNCTION_TEST_MODE, UDF_TEST_MODE}; +use sqlbuild_core::text::main::is_python_space::is_python_space; +use sqlbuild_sqltext::sql_scan::main::dialect_matching_paren::dialect_matching_paren; +use sqlbuild_sqltext::sql_scan::main::dialect_non_code_end::dialect_non_code_end; +use sqlbuild_sqltext::sql_scan::models::{LexicalSyntax, Unclosed}; + +const PREFIXES: [(&str, &str); 6] = [ + ("__dbt_ref(", DBT_REF_REFERENCE_KIND), + ("__table_fn(", TABLE_FUNCTION_TEST_MODE), + ("__source(", "source"), + ("__seed(", "seed"), + ("__udf(", UDF_TEST_MODE), + ("__ref(", "ref"), +]; +const REFERENCE_CONTEXT: &str = "SQL reference"; +const TABLE_FUNCTION_CALL_CONTEXT: &str = "SQL table function call"; + +/// The calls of one body that matter to direct-logic validation. +#[derive(Debug, Default, PartialEq, Eq)] +pub(crate) struct BodyCalls { + /// The kind of the first valid `__udf` or `__table_fn` call, in text order. + pub(crate) first_logic_kind: Option<&'static str>, + /// Whether any reference call is malformed, which Python reports as P012. + pub(crate) invalid: bool, +} + +/// Read every reference call outside comments and quoted text under the adapter's rules. +pub(crate) fn body_calls(sql: &str, syntax: &LexicalSyntax) -> Result { + let bytes = sql.as_bytes(); + let mut calls = BodyCalls::default(); + let mut index = 0; + while index < bytes.len() { + if let Some(end) = dialect_non_code_end(bytes, index, syntax) + .map_err(|error| unclosed(REFERENCE_CONTEXT, error))? + { + index = end; + continue; + } + let Some((prefix, kind)) = PREFIXES + .iter() + .find(|(prefix, _)| bytes[index..].starts_with(prefix.as_bytes())) + else { + index += 1; + continue; + }; + let open = index + prefix.len() - 1; + let close = paren(bytes, open, syntax, REFERENCE_CONTEXT)?; + let arguments = &sql[open + 1..close]; + let mut valid = if *kind == DBT_REF_REFERENCE_KIND { + dbt_arguments_match(arguments) + } else { + name_argument_matches(arguments) + }; + if valid && *kind == TABLE_FUNCTION_TEST_MODE { + let suffix = python_space_end(sql, close + 1); + if bytes.get(suffix) == Some(&b'(') { + let _ = paren(bytes, suffix, syntax, TABLE_FUNCTION_CALL_CONTEXT)?; + } else { + valid = false; + } + } + if !valid { + calls.invalid = true; + } else if calls.first_logic_kind.is_none() + && (*kind == UDF_TEST_MODE || *kind == TABLE_FUNCTION_TEST_MODE) + { + calls.first_logic_kind = Some(kind); + } + index = close + 1; + } + Ok(calls) +} + +/// Python `"([^"]+)"` matched against the whole argument text. +fn name_argument_matches(arguments: &str) -> bool { + quoted_name_end(arguments, 0) == Some(arguments.len()) +} + +/// Python `"([^"]+)"(?:\s*,\s*"([^"]+)")?` matched against the whole argument text. +fn dbt_arguments_match(arguments: &str) -> bool { + let Some(first_end) = quoted_name_end(arguments, 0) else { + return false; + }; + if first_end == arguments.len() { + return true; + } + let separator = python_space_end(arguments, first_end); + if arguments.as_bytes().get(separator) != Some(&b',') { + return false; + } + let second = python_space_end(arguments, separator + 1); + quoted_name_end(arguments, second) == Some(arguments.len()) +} + +/// The offset after a `"name"` with at least one character starting at `start`. +fn quoted_name_end(arguments: &str, start: usize) -> Option { + let bytes = arguments.as_bytes(); + if bytes.get(start) != Some(&b'"') { + return None; + } + let close = start + 1 + bytes[start + 1..].iter().position(|byte| *byte == b'"')?; + (close > start + 1).then_some(close + 1) +} + +fn python_space_end(sql: &str, start: usize) -> usize { + let mut index = start; + while let Some(character) = sql.get(index..).and_then(|rest| rest.chars().next()) { + if !is_python_space(character) { + break; + } + index += character.len_utf8(); + } + index +} + +fn paren(sql: &[u8], open: usize, syntax: &LexicalSyntax, context: &str) -> Result { + dialect_matching_paren(sql, open, syntax).map_err(|error| unclosed(context, error)) +} + +fn unclosed(context: &str, error: Unclosed) -> String { + let construct = match error { + Unclosed::BlockComment => "block comment", + Unclosed::Quote => "quoted string", + Unclosed::Parenthesis => "parenthesis", + }; + format!("{context} contains an unclosed {construct}") +} diff --git a/crates/sqlbuild-analysis/src/compiler/_helpers/sql_tests/scenario_extraction.rs b/crates/sqlbuild-analysis/src/compiler/_helpers/sql_tests/scenario_extraction.rs index fa12bf8f6d..54704d9e75 100644 --- a/crates/sqlbuild-analysis/src/compiler/_helpers/sql_tests/scenario_extraction.rs +++ b/crates/sqlbuild-analysis/src/compiler/_helpers/sql_tests/scenario_extraction.rs @@ -3,7 +3,7 @@ use serde::Serialize; use crate::compiler::_helpers::sql_tests::extraction::{ - Cte, extract_ctes_with_quoting, validate_independence, + Cte, NameRule, extract_ctes_with_quoting, generic_syntax, validate_independence, }; /// Characters whose Python case mapping reaches ASCII, which Python's keyword match would accept. @@ -33,13 +33,15 @@ pub(crate) fn extract_scenario_json(sql: &str, file: &str) -> Result SqlTestFixtureFacts { name.strip_prefix(prefix) .is_some_and(|target| !target.is_empty()) }), - empty_fixture_marker: matches!(extraction::empty_fixture_marker_matches(sql), Ok(true)), + empty_fixture_marker: matches!( + extraction::empty_fixture_marker_matches(sql, &extraction::generic_syntax(),), + Ok(true) + ), } } diff --git a/crates/sqlbuild-analysis/src/compiler/tests.rs b/crates/sqlbuild-analysis/src/compiler/tests.rs index a057d9d00e..cc4183b024 100644 --- a/crates/sqlbuild-analysis/src/compiler/tests.rs +++ b/crates/sqlbuild-analysis/src/compiler/tests.rs @@ -10,6 +10,8 @@ mod sql_test_cte_slices; mod sql_test_expected_columns; #[path = "tests/test_sql_test_extraction.rs"] mod sql_test_extraction; +#[path = "tests/test_sql_test_extraction_rules.rs"] +mod sql_test_extraction_rules; #[path = "tests/test_sql_test_helper_references.rs"] mod sql_test_helper_references; #[path = "tests/test_sql_test_markers.rs"] diff --git a/crates/sqlbuild-analysis/src/compiler/tests/helpers.rs b/crates/sqlbuild-analysis/src/compiler/tests/helpers.rs index 3c1d87c438..bc61f1f4ac 100644 --- a/crates/sqlbuild-analysis/src/compiler/tests/helpers.rs +++ b/crates/sqlbuild-analysis/src/compiler/tests/helpers.rs @@ -9,12 +9,13 @@ use crate::compiler::_helpers::sql_tests::cte_rename::defined_cte_keys; use crate::compiler::_helpers::sql_tests::cte_slices::{SliceDialect, split_top_level_with}; use crate::compiler::_helpers::sql_tests::relation_markers::relation_marker_calls; use crate::compiler::main::sql_test_extraction::extract_batch_json; +use crate::compiler::tests::test_types::ExtractionRuleTestCase; use crate::compiler::tests::test_types::{ HelperReferenceTestCase, PlanShape, RenderedShapeTestCase, SharedNameTestCase, }; pub(crate) fn mixed_expanded_tests_preserve_order_and_payloads() -> bool { - let response = extract_batch_json(r#"{"tests":[{"sql":"WITH helper AS (SELECT 1 AS id), __source__raw_orders AS (SELECT id FROM helper), __expected__orders AS (SELECT id FROM helper), __assert__positive AS (SELECT id FROM helper WHERE id < 0) SELECT 1","fileLabel":"tests/orders.sql","mode":"model"},{"sql":"WITH input AS (SELECT 1 AS value), __udf_actual__ AS (SELECT __udf(\"increment\")(value) AS value FROM input), __udf_expected__ AS (SELECT 2 AS value) SELECT 1","fileLabel":"tests/increment.sql","mode":"udf"}]}"#).expect("batch succeeds"); + let response = extracted(r#"{"tests":[{"sql":"WITH helper AS (SELECT 1 AS id), __source__raw_orders AS (SELECT id FROM helper), __expected__orders AS (SELECT id FROM helper), __assert__positive AS (SELECT id FROM helper WHERE id < 0) SELECT 1","fileLabel":"tests/orders.sql","mode":"model"},{"sql":"WITH input AS (SELECT 1 AS value), __udf_actual__ AS (SELECT __udf(\"increment\")(value) AS value FROM input), __udf_expected__ AS (SELECT 2 AS value) SELECT 1","fileLabel":"tests/increment.sql","mode":"udf"}]}"#).expect("batch succeeds"); let payload: serde_json::Value = serde_json::from_str(&response).expect("valid JSON"); assert_eq!(payload[0]["kind"], "model"); assert_eq!(payload[0]["authored"][0][0], "helper"); @@ -27,35 +28,27 @@ pub(crate) fn mixed_expanded_tests_preserve_order_and_payloads() -> bool { } pub(crate) fn trailing_ceremonial_select_is_optional() -> bool { - let response = extract_batch_json(r#"{"tests":[{"sql":"WITH __source__raw_orders AS (SELECT 1 AS id), __expected__orders AS (SELECT 1 AS id); -- done","fileLabel":"tests/orders.sql","mode":"model"}]}"#).expect("omitted select succeeds"); + let response = extracted(r#"{"tests":[{"sql":"WITH __source__raw_orders AS (SELECT 1 AS id), __expected__orders AS (SELECT 1 AS id); -- done","fileLabel":"tests/orders.sql","mode":"model"}]}"#).expect("omitted select succeeds"); let payload: serde_json::Value = serde_json::from_str(&response).expect("valid JSON"); assert_eq!(payload[0]["expectedModels"][0], "orders"); - let error = extract_batch_json(r#"{"tests":[{"sql":"WITH __source__raw_orders AS (SELECT 1 AS id), __expected__orders AS (SELECT 1 AS id) SELECT * FROM __expected__orders","fileLabel":"tests/orders.sql","mode":"model"}]}"#).expect_err("other final statement is rejected"); + let error = extracted(r#"{"tests":[{"sql":"WITH __source__raw_orders AS (SELECT 1 AS id), __expected__orders AS (SELECT 1 AS id) SELECT * FROM __expected__orders","fileLabel":"tests/orders.sql","mode":"model"}]}"#).expect_err("other final statement is rejected"); assert!(error.contains("must end after its CTEs")); true } pub(crate) fn dependent_assertion_returns_authoritative_error() -> bool { - let error = extract_batch_json(r#"{"tests":[{"sql":"WITH __source__raw_orders AS (SELECT 1 AS id), __expected__orders AS (SELECT 1 AS id), __assert__same AS (SELECT id FROM __expected__orders) SELECT 1","fileLabel":"tests/orders.sql","mode":"model"}]}"#).expect_err("dependency is rejected"); + let error = extracted(r#"{"tests":[{"sql":"WITH __source__raw_orders AS (SELECT 1 AS id), __expected__orders AS (SELECT 1 AS id), __assert__same AS (SELECT id FROM __expected__orders) SELECT 1","fileLabel":"tests/orders.sql","mode":"model"}]}"#).expect_err("dependency is rejected"); assert!(error.contains("'__assert__same' must not depend on '__expected__orders'")); true } -pub(crate) fn quoted_ctes_and_implicit_alias_preserve_payload() -> bool { - let response = extract_batch_json(r#"{"tests":[{"sql":"WITH \"__source__raw_orders\" AS (SELECT 1 AS id), \"__expected__orders\" AS (SELECT CAST(1 AS INTEGER) id) SELECT 1","fileLabel":"tests/orders.sql","mode":"model"}]}"#).expect("batch succeeds"); - let payload: serde_json::Value = serde_json::from_str(&response).expect("valid JSON"); - assert_eq!(payload[0]["authored"][0][0], "__source__raw_orders"); - assert_eq!(payload[0]["expected"][0][1], "SELECT CAST(1 AS INTEGER) id"); - true -} - pub(crate) fn empty_model_fixture_marker_preserves_direct_mode_validation() -> bool { - let response = extract_batch_json(r#"{"tests":[{"sql":"WITH __source__raw_orders AS (SELECT * FROM __empty_fixture()), __expected__orders AS (SELECT * FROM __empty_fixture()) SELECT 1","fileLabel":"tests/orders.sql","mode":"model"}]}"#).expect("model marker succeeds"); + let response = extracted(r#"{"tests":[{"sql":"WITH __source__raw_orders AS (SELECT * FROM __empty_fixture()), __expected__orders AS (SELECT * FROM __empty_fixture()) SELECT 1","fileLabel":"tests/orders.sql","mode":"model"}]}"#).expect("model marker succeeds"); let payload: serde_json::Value = serde_json::from_str(&response).expect("valid JSON"); assert_eq!(payload[0]["expectedModels"][0], "orders"); - let error = extract_batch_json(r#"{"tests":[{"sql":"WITH __udf_actual__ AS (SELECT 1 AS value), __udf_expected__ AS (SELECT * FROM __empty_fixture()) SELECT 1","fileLabel":"tests/function.sql","mode":"udf"}]}"#).expect_err("direct marker is rejected"); + let error = extracted(r#"{"tests":[{"sql":"WITH __udf_actual__ AS (SELECT 1 AS value), __udf_expected__ AS (SELECT * FROM __empty_fixture()) SELECT 1","fileLabel":"tests/function.sql","mode":"udf"}]}"#).expect_err("direct marker is rejected"); assert!(error.contains("must not use SELECT * in __udf_expected__ CTEs")); true } @@ -76,12 +69,22 @@ pub(crate) fn expected_projection_errors_name_the_expected_cte() -> bool { ), ]; for (request, expected_error) in cases { - let error = extract_batch_json(request).expect_err("expected projection is rejected"); + let error = extracted(request).expect_err("expected projection is rejected"); assert_eq!(error, expected_error); } true } +/// The extracted tests as JSON, or the message of the batch's first error. +pub(crate) fn extracted(request: &str) -> Result { + let response = + batch_response(&serde_json::from_str(request).map_err(|error| error.to_string())?); + response.get("error").map_or_else( + || Ok(response["tests"].to_string()), + |error| Err(error["message"].as_str().unwrap_or_default().to_owned()), + ) +} + fn extract_expected( mode: &str, actual: &str, @@ -89,7 +92,7 @@ fn extract_expected( expected_sql: &str, ) -> Result { let sql = format!("WITH {actual}, {expected_name} AS ({expected_sql}) SELECT 1"); - extract_batch_json( + extracted( &json!({"tests": [{"sql": sql, "fileLabel": "tests/orders.sql", "mode": mode}]}) .to_string(), ) @@ -1935,3 +1938,61 @@ fn with_empty_compiler_reads(request_json: &str) -> String { } request.to_string() } + +/// The lexical rules of each adapter family the rule cases use, as the Python adapters declare them. +const LEXICAL_SYNTAXES: &str = r##"{"bigquery": {"backslashEscapeQuotes": ["\"", "'", "`"], "escapeStringPrefix": false, "rawStringPrefix": true, "tripleQuotedStrings": true, "nestedBlockComments": false, "lineCommentPrefixes": ["#", "--"]}, "duckdb": {"backslashEscapeQuotes": [], "escapeStringPrefix": true, "rawStringPrefix": false, "tripleQuotedStrings": false, "nestedBlockComments": true, "lineCommentPrefixes": ["--"]}, "snowflake": {"backslashEscapeQuotes": ["'"], "escapeStringPrefix": false, "rawStringPrefix": false, "tripleQuotedStrings": false, "nestedBlockComments": false, "lineCommentPrefixes": ["--", "//"]}, "generic": {"backslashEscapeQuotes": [], "escapeStringPrefix": false, "rawStringPrefix": false, "tripleQuotedStrings": false, "nestedBlockComments": false, "lineCommentPrefixes": ["--"]}}"##; + +/// Extract one rule case and describe the outcome as `accepted_outcome` or `rejected_outcome` do. +pub(crate) fn extraction_rule_outcome(test_case: &ExtractionRuleTestCase) -> String { + let syntaxes: Value = serde_json::from_str(LEXICAL_SYNTAXES).expect("the syntaxes are JSON"); + let response = batch_response(&json!({ + "syntax": syntaxes[test_case.syntax], + "tests": [{"sql": test_case.sql, "fileLabel": "tests/t.sql", "mode": test_case.mode, "raw": test_case.raw}], + })); + let invalid_calls = response["tests"][0]["invalidCalls"] + .as_bool() + .unwrap_or(false); + response.get("error").map_or_else( + || accepted_outcome(invalid_calls), + |error| { + let offset = error["tokenOffset"] + .as_u64() + .and_then(|offset| usize::try_from(offset).ok()); + rejected_outcome( + error["message"].as_str().unwrap_or_default(), + error["help"].as_str(), + error["token"].as_str().zip(offset), + ) + }, + ) +} + +fn batch_response(request: &Value) -> Value { + serde_json::from_str(&extract_batch_json(&request.to_string()).expect("the request is valid")) + .expect("the response is JSON") +} + +/// An accepted test; `invalid_calls` when a helper or expected CTE has a malformed reference call. +pub(crate) fn accepted_outcome(invalid_calls: bool) -> String { + format!("accepted: invalid calls {invalid_calls}") +} + +/// A rejected test's first error message, its help, and its offending text with its offset. +pub(crate) fn rejected_outcome( + message: &str, + help: Option<&str>, + token: Option<(&str, usize)>, +) -> String { + format!( + "rejected: {message} | help: {} | token: {}", + help.map_or(Value::Null, |help| Value::String(help.to_owned())), + token.map_or(json!([null, null]), |(text, offset)| json!([text, offset])), + ) +} + +/// The authored-block reading of `sql`: its CTEs with body offsets, or its first error. +pub(crate) fn authored_ctes_response(sql: &str) -> Value { + batch_response( + &json!({"tests": [{"sql": sql, "fileLabel": "tests/t.sql", "mode": "model", "authored": true}]}), + ) +} diff --git a/crates/sqlbuild-analysis/src/compiler/tests/test_sql_test_extraction.rs b/crates/sqlbuild-analysis/src/compiler/tests/test_sql_test_extraction.rs index 825e9bdf5a..5e85e96b94 100644 --- a/crates/sqlbuild-analysis/src/compiler/tests/test_sql_test_extraction.rs +++ b/crates/sqlbuild-analysis/src/compiler/tests/test_sql_test_extraction.rs @@ -1,12 +1,11 @@ use crate::compiler::_helpers::sql_tests::extraction::{ - find_top_level_keyword, split_set_operations, split_top_level, + find_top_level_keyword, generic_syntax, split_set_operations, split_top_level, }; use crate::compiler::tests::helpers::{ dependent_assertion_returns_authoritative_error, empty_model_fixture_marker_preserves_direct_mode_validation, expected_projection_errors_name_the_expected_cte, mixed_expanded_tests_preserve_order_and_payloads, - quoted_ctes_and_implicit_alias_preserve_payload, set_operation_expected_ctes_validate_every_branch, trailing_ceremonial_select_is_optional, }; use crate::compiler::tests::test_types::{SqlTestExtractionTestCase, TopLevelScanTestCase}; @@ -24,11 +23,6 @@ fn given_sql_test_cases_when_extracting_native_payloads_then_expected_behavior_h run: dependent_assertion_returns_authoritative_error, expected_success: true, }, - SqlTestExtractionTestCase { - description: "quoted CTEs and implicit aliases preserve payloads", - run: quoted_ctes_and_implicit_alias_preserve_payload, - expected_success: true, - }, SqlTestExtractionTestCase { description: "empty model fixture marker preserves direct-mode validation", run: empty_model_fixture_marker_preserves_direct_mode_validation, @@ -165,6 +159,18 @@ fn given_expected_cte_sql_when_scanning_top_level_then_set_operations_commas_and ]), expected_from: Ok(Some(54)), }, + TopLevelScanTestCase { + description: "commas inside brackets and braces do not split", + sql: "SELECT [1, 2] AS status, {'a': 1, 'b': 2} AS m FROM t", + expected_unions: Ok(vec![ + "SELECT [1, 2] AS status, {'a': 1, 'b': 2} AS m FROM t", + ]), + expected_commas: Ok(vec![ + "SELECT [1, 2] AS status", + "{'a': 1, 'b': 2} AS m FROM t", + ]), + expected_from: Ok(Some(47)), + }, TopLevelScanTestCase { description: "unclosed quote", sql: "SELECT 'a, b FROM t", @@ -182,19 +188,19 @@ fn given_expected_cte_sql_when_scanning_top_level_then_set_operations_commas_and ]; for test_case in test_cases { assert_eq!( - split_set_operations(test_case.sql), + split_set_operations(test_case.sql, &generic_syntax()), test_case.expected_unions, "unions: {}", test_case.description ); assert_eq!( - split_top_level(test_case.sql, b','), + split_top_level(test_case.sql, b',', &generic_syntax()), test_case.expected_commas, "commas: {}", test_case.description ); assert_eq!( - find_top_level_keyword(test_case.sql, 0, "FROM"), + find_top_level_keyword(test_case.sql, 0, "FROM", &generic_syntax()), test_case.expected_from, "FROM: {}", test_case.description diff --git a/crates/sqlbuild-analysis/src/compiler/tests/test_sql_test_extraction_rules.rs b/crates/sqlbuild-analysis/src/compiler/tests/test_sql_test_extraction_rules.rs new file mode 100644 index 0000000000..bdcab5353f --- /dev/null +++ b/crates/sqlbuild-analysis/src/compiler/tests/test_sql_test_extraction_rules.rs @@ -0,0 +1,421 @@ +use crate::compiler::tests::helpers::{ + accepted_outcome, extraction_rule_outcome, rejected_outcome, +}; +use serde_json::json; + +use crate::compiler::tests::helpers::authored_ctes_response; +use crate::compiler::tests::test_types::{AuthoredCtesTestCase, ExtractionRuleTestCase}; + +const NAME_HELP: Option<&str> = Some( + "rename the CTE, for example my_helper; quoted CTE names and names with $ or non-ASCII characters are not supported", +); + +#[test] +fn given_authored_sql_tests_when_extracting_then_each_rule_holds() { + let test_cases = [ + ExtractionRuleTestCase { + description: "a double-quoted CTE name is rejected at its position", + mode: "udf", + raw: true, + syntax: "generic", + sql: "WITH \"my helper\" AS (SELECT 1 AS a), __udf_actual__ AS (SELECT 1 AS a), __udf_expected__ AS (SELECT 1 AS a)", + expected_outcome: rejected_outcome( + "SQL test 'tests/t.sql' CTE name \"my helper\" must be an unquoted identifier of ASCII letters, digits and underscores", + NAME_HELP, + Some(("\"my helper\"", 5)), + ), + }, + ExtractionRuleTestCase { + description: "a backtick CTE name is rejected", + mode: "model", + raw: false, + syntax: "bigquery", + sql: "WITH __ref__orders AS (SELECT 1 AS id), `my helper` AS (SELECT 1 AS a), __expected__orders AS (SELECT 1 AS id)", + expected_outcome: rejected_outcome( + "SQL test 'tests/t.sql' CTE name `my helper` must be an unquoted identifier of ASCII letters, digits and underscores", + NAME_HELP, + Some(("`my helper`", 40)), + ), + }, + ExtractionRuleTestCase { + description: "a CTE name with $ is rejected whole", + mode: "macro", + raw: true, + syntax: "generic", + sql: "WITH my$helper AS (SELECT 1 AS a), __macro_actual__ AS (SELECT @m(1) AS a), __macro_expected__ AS (SELECT 1 AS a)", + expected_outcome: rejected_outcome( + "SQL test 'tests/t.sql' CTE name 'my$helper' must be an unquoted identifier of ASCII letters, digits and underscores", + NAME_HELP, + Some(("my$helper", 5)), + ), + }, + ExtractionRuleTestCase { + description: "a non-ASCII CTE name is rejected with a code-point offset", + mode: "udf", + raw: false, + syntax: "generic", + sql: "WITH __udf_actual__ AS (SELECT 'é' AS a), helpér AS (SELECT 1 AS a), __udf_expected__ AS (SELECT 1 AS a)", + expected_outcome: rejected_outcome( + "SQL test 'tests/t.sql' CTE name 'helpér' must be an unquoted identifier of ASCII letters, digits and underscores", + Some( + "rename the CTE, for example help_r; quoted CTE names and names with $ or non-ASCII characters are not supported", + ), + Some(("helpér", 42)), + ), + }, + ExtractionRuleTestCase { + description: "keywords match ASCII case-insensitively only", + mode: "udf", + raw: true, + syntax: "generic", + sql: "WITH __udf_actual__ aſ (SELECT 1 AS a), __udf_expected__ AS (SELECT 1 AS a)", + expected_outcome: rejected_outcome( + "SQL test 'tests/t.sql' expected keyword AS", + None, + None, + ), + }, + ExtractionRuleTestCase { + description: "Python whitespace separates tokens and is stripped from bodies", + mode: "udf", + raw: true, + syntax: "generic", + sql: "WITH __udf_actual__\u{1c}AS (SELECT __udf(\"f\")(1) AS a\u{1c}), __udf_expected__\u{a0}AS (SELECT 1 AS a)", + expected_outcome: accepted_outcome(false), + }, + ExtractionRuleTestCase { + description: "a UDF call followed by a no-break space in an expected CTE does not panic", + mode: "udf", + raw: true, + syntax: "generic", + sql: "WITH __udf_actual__ AS (SELECT __udf(\"f\")(1) AS a), __udf_expected__ AS (SELECT __udf\u{a0}(\"f\")(1))", + expected_outcome: rejected_outcome( + "SQL test 'tests/t.sql' must alias every non-trivial __udf_expected__ projection", + Some( + "name the column with an alias, for example __udf\u{a0}(\"f\")(1) AS ; only a bare or qualified column reference may go unaliased", + ), + None, + ), + }, + ExtractionRuleTestCase { + description: "an implicit alias needs AS", + mode: "model", + raw: false, + syntax: "generic", + sql: "WITH __ref__orders AS (SELECT 1 AS id), __expected__orders AS (SELECT a IS NULL) SELECT 1", + expected_outcome: rejected_outcome( + "SQL test 'tests/t.sql' must alias every non-trivial __expected__orders projection", + Some( + "name the column with an alias, for example a IS NULL AS ; only a bare or qualified column reference may go unaliased", + ), + None, + ), + }, + ExtractionRuleTestCase { + description: "implicit aliases name literals, casts and quoted columns", + mode: "model", + raw: false, + syntax: "generic", + sql: "WITH __ref__orders AS (SELECT 1 AS id), __expected__orders AS (SELECT 1 order_id, CAST(100 AS INTEGER) customer_id, 'x' \"Status\", x::int amount, CASE WHEN a THEN 1 END /* c */ flag, a IS NULL missing) SELECT 1", + expected_outcome: accepted_outcome(false), + }, + ExtractionRuleTestCase { + description: "a trailing value keyword is not an implicit alias", + mode: "model", + raw: false, + syntax: "generic", + sql: "WITH __ref__orders AS (SELECT 1 AS id), __expected__orders AS (SELECT CASE WHEN a THEN 1 END) SELECT 1", + expected_outcome: rejected_outcome( + "SQL test 'tests/t.sql' must alias every non-trivial __expected__orders projection", + Some( + "name the column with an alias, for example CASE WHEN a THEN 1 END AS ; only a bare or qualified column reference may go unaliased", + ), + None, + ), + }, + ExtractionRuleTestCase { + description: "a token after an operator keyword is an operand, not an implicit alias", + mode: "udf", + raw: false, + syntax: "generic", + sql: "WITH __udf_actual__ AS (SELECT 1 AS a), __udf_expected__ AS (SELECT x BETWEEN 1 AND y)", + expected_outcome: rejected_outcome( + "SQL test 'tests/t.sql' must alias every non-trivial __udf_expected__ projection", + Some( + "name the column with an alias, for example x BETWEEN 1 AND y AS ; only a bare or qualified column reference may go unaliased", + ), + None, + ), + }, + ExtractionRuleTestCase { + description: "a token after an operator character is an operand, not an implicit alias", + mode: "udf", + raw: false, + syntax: "generic", + sql: "WITH __udf_actual__ AS (SELECT 1 AS a), __udf_expected__ AS (SELECT a + b)", + expected_outcome: rejected_outcome( + "SQL test 'tests/t.sql' must alias every non-trivial __udf_expected__ projection", + Some( + "name the column with an alias, for example a + b AS ; only a bare or qualified column reference may go unaliased", + ), + None, + ), + }, + ExtractionRuleTestCase { + description: "IS [NOT] DISTINCT FROM stays in the projection and its alias is accepted", + mode: "model", + raw: false, + syntax: "generic", + sql: "WITH __ref__orders AS (SELECT 1 AS id), __expected__orders AS (SELECT 'completed' IS DISTINCT FROM 'x' AS status, a IS NOT /* c */ DISTINCT FROM b AS same FROM __ref__orders) SELECT 1", + expected_outcome: accepted_outcome(false), + }, + ExtractionRuleTestCase { + description: "IS DISTINCT FROM without an alias is rejected", + mode: "model", + raw: false, + syntax: "generic", + sql: "WITH __ref__orders AS (SELECT 1 AS id), __expected__orders AS (SELECT 'completed' IS DISTINCT FROM 'x') SELECT 1", + expected_outcome: rejected_outcome( + "SQL test 'tests/t.sql' must alias every non-trivial __expected__orders projection", + Some( + "name the column with an alias, for example 'completed' IS DISTINCT FROM 'x' AS ; only a bare or qualified column reference may go unaliased", + ), + None, + ), + }, + ExtractionRuleTestCase { + description: "the operand after IS NOT DISTINCT FROM is not an implicit alias", + mode: "model", + raw: false, + syntax: "generic", + sql: "WITH __ref__orders AS (SELECT 1 AS id), __expected__orders AS (SELECT a IS NOT DISTINCT FROM b) SELECT 1", + expected_outcome: rejected_outcome( + "SQL test 'tests/t.sql' must alias every non-trivial __expected__orders projection", + Some( + "name the column with an alias, for example a IS NOT DISTINCT FROM b AS ; only a bare or qualified column reference may go unaliased", + ), + None, + ), + }, + ExtractionRuleTestCase { + description: "a bracketed list with an alias is one projection", + mode: "model", + raw: false, + syntax: "duckdb", + sql: "WITH __ref__orders AS (SELECT 1 AS id), __expected__orders AS (SELECT [1, 2] AS status, {'a': 1, 'b': 2} AS details) SELECT 1", + expected_outcome: accepted_outcome(false), + }, + ExtractionRuleTestCase { + description: "bare, qualified and DISTINCT column references go unaliased", + mode: "model", + raw: false, + syntax: "generic", + sql: "WITH __ref__orders AS (SELECT 1 AS id), __expected__orders AS (SELECT DISTINCT o.id, \"Status\", s.t.amount FROM __ref__orders o) SELECT 1", + expected_outcome: accepted_outcome(false), + }, + ExtractionRuleTestCase { + description: "a qualified star is SELECT *", + mode: "model", + raw: false, + syntax: "generic", + sql: "WITH __ref__orders AS (SELECT 1 AS id), __expected__orders AS (SELECT o.* FROM __ref__orders o)", + expected_outcome: rejected_outcome( + "SQL test 'tests/t.sql' must not use SELECT * in __expected__orders CTEs", + None, + None, + ), + }, + ExtractionRuleTestCase { + description: "SELECT DISTINCT * is SELECT *", + mode: "udf", + raw: false, + syntax: "generic", + sql: "WITH __udf_actual__ AS (SELECT 1 AS a), __udf_expected__ AS (SELECT DISTINCT * FROM h), h AS (SELECT 1 AS a)", + expected_outcome: rejected_outcome( + "SQL test 'tests/t.sql' must not use SELECT * in __udf_expected__ CTEs", + None, + None, + ), + }, + ExtractionRuleTestCase { + description: "parenthesised expected branches are rejected", + mode: "udf", + raw: true, + syntax: "generic", + sql: "WITH __udf_actual__ AS (SELECT 1 AS a), __udf_expected__ AS ((SELECT 1 AS a) UNION ALL (SELECT 2 AS a))", + expected_outcome: rejected_outcome( + "SQL test 'tests/t.sql' must write each __udf_expected__ set-operation branch as a plain SELECT without enclosing parentheses", + None, + None, + ), + }, + ExtractionRuleTestCase { + description: "WITH inside an expected CTE is rejected with help", + mode: "model", + raw: false, + syntax: "generic", + sql: "WITH __ref__orders AS (SELECT 1 AS id), __expected__orders AS (WITH rows_in AS (SELECT 1 AS id) SELECT id FROM rows_in)", + expected_outcome: rejected_outcome( + "SQL test 'tests/t.sql' must not use WITH inside __expected__orders", + Some( + "define the shared rows as a helper CTE in the test's top-level WITH clause and select from it", + ), + None, + ), + }, + ExtractionRuleTestCase { + description: "a macro-test helper may not call a UDF", + mode: "macro", + raw: true, + syntax: "generic", + sql: "WITH h AS (SELECT __udf(\"f\")(1) AS a), __macro_actual__ AS (SELECT @m(1) AS a), __macro_expected__ AS (SELECT 1 AS a)", + expected_outcome: rejected_outcome( + "SQL test 'tests/t.sql' mode 'macro' helper CTE 'h' must not call udf; call reusable logic only in __macro_actual__", + None, + None, + ), + }, + ExtractionRuleTestCase { + description: "the first logic call in text order names the kind", + mode: "udf", + raw: true, + syntax: "generic", + sql: "WITH __udf_actual__ AS (SELECT 1 AS a), __udf_expected__ AS (SELECT __table_fn(\"t\")(1) + __udf(\"f\")(1) AS a)", + expected_outcome: rejected_outcome( + "SQL test 'tests/t.sql' mode 'udf' CTE __udf_expected__ must not call table_fn; call reusable logic only in __udf_actual__", + None, + None, + ), + }, + ExtractionRuleTestCase { + description: "malformed reference calls are flagged for P012 instead of read as logic calls", + mode: "table_fn", + raw: true, + syntax: "generic", + sql: "WITH h AS (SELECT * FROM __table_fn(\"t\")), __table_fn_actual__ AS (SELECT * FROM __table_fn(\"t\")(1)), __table_fn_expected__ AS (SELECT __source(\"s\", \"t\") AS a)", + expected_outcome: accepted_outcome(true), + }, + ExtractionRuleTestCase { + description: "a raw helper calling a macro names the allowed location", + mode: "macro", + raw: true, + syntax: "generic", + sql: "WITH h AS (SELECT @tidy(a) AS a), __macro_actual__ AS (SELECT @m(1) AS a), __macro_expected__ AS (SELECT 1 AS a)", + expected_outcome: rejected_outcome( + "SQL test 'tests/t.sql' mode 'macro' helper CTE 'h' must not call macros; call macros only in __macro_actual__", + None, + None, + ), + }, + ExtractionRuleTestCase { + description: "raw macro detection counts @var but not declaration calls", + mode: "udf", + raw: true, + syntax: "generic", + sql: "WITH __udf_actual__ AS (SELECT __udf(\"f\")(1) AS a), __udf_expected__ AS (SELECT @var('x') AS a)", + expected_outcome: rejected_outcome( + "SQL test 'tests/t.sql' mode 'udf' CTE __udf_expected__ must not call macros", + None, + None, + ), + }, + ExtractionRuleTestCase { + description: "raw macro detection skips enum and const and their arguments", + mode: "udf", + raw: true, + syntax: "generic", + sql: "WITH __udf_actual__ AS (SELECT __udf(\"f\")(1) AS a), __udf_expected__ AS (SELECT @enum('Status', 'PAID') AS a, @const(@m(1)) AS b)", + expected_outcome: accepted_outcome(false), + }, + ExtractionRuleTestCase { + description: "BigQuery hash comments hide calls and parentheses", + mode: "udf", + raw: true, + syntax: "bigquery", + sql: "WITH __udf_actual__ AS (SELECT __udf(\"f\")(1) AS a), # __udf(\"g\")(1) ),\n__udf_expected__ AS (SELECT 'it\\'s' AS a # __udf(\"g\")(\n)", + expected_outcome: accepted_outcome(false), + }, + ExtractionRuleTestCase { + description: "DuckDB nested comments and E-strings follow DuckDB rules", + mode: "udf", + raw: true, + syntax: "duckdb", + sql: "WITH __udf_actual__ AS (SELECT __udf(\"f\")(1) AS a /* x /* ) */ */), __udf_expected__ AS (SELECT E'\\'' AS a)", + expected_outcome: accepted_outcome(false), + }, + ExtractionRuleTestCase { + description: "generic rules end a block comment at its first close", + mode: "udf", + raw: true, + syntax: "generic", + sql: "WITH __udf_actual__ AS (SELECT 1 AS a), __udf_expected__ AS (SELECT 1 AS a /* x /* y */ */)", + expected_outcome: rejected_outcome( + "SQL test 'tests/t.sql' must alias every non-trivial __udf_expected__ projection", + Some( + "name the column with an alias, for example 1 AS a /* x /* y */ */ AS ; only a bare or qualified column reference may go unaliased", + ), + None, + ), + }, + ExtractionRuleTestCase { + description: "a UDF-test CTE in a model test names the UDF mode", + mode: "model", + raw: false, + syntax: "generic", + sql: "WITH __ref__orders AS (SELECT 1 AS id), __udf_actual__ AS (SELECT 1 AS a)", + expected_outcome: rejected_outcome( + "SQL test 'tests/t.sql' is mode 'model' but defines UDF-test CTE '__udf_actual__'; use TEST (mode udf)", + None, + None, + ), + }, + ]; + + for test_case in test_cases { + assert_eq!( + extraction_rule_outcome(&test_case), + test_case.expected_outcome, + "{}", + test_case.description + ); + } +} + +#[test] +fn given_authored_blocks_when_reading_ctes_then_offsets_and_name_errors_are_exact() { + let test_cases = [ + AuthoredCtesTestCase { + description: "body offsets count code points and skip leading whitespace", + sql: "-- café\nWITH h AS ( SELECT 'é' AS a), __ref__orders AS (\n SELECT 1)", + expected_response: json!({"tests": [{"kind": "authored", "ctes": [ + ["h", 21, "SELECT 'é' AS a"], + ["__ref__orders", 59, "SELECT 1"], + ]}]}), + }, + AuthoredCtesTestCase { + description: "a CTE name error keeps its code-point offset", + sql: "-- é\nWITH h AS (SELECT 1), café AS (SELECT 2)", + expected_response: json!({"error": { + "index": 0, + "message": "SQL test 'tests/t.sql' CTE name 'café' must be an unquoted identifier of ASCII letters, digits and underscores", + "help": "rename the CTE, for example caf_; quoted CTE names and names with $ or non-ASCII characters are not supported", + "token": "café", + "tokenOffset": 27, + }}), + }, + AuthoredCtesTestCase { + description: "other errors are left to extraction and read as no CTEs", + sql: "WITH h AS (SELECT 1) SELECT * FROM h", + expected_response: json!({"tests": [{"kind": "authored", "ctes": []}]}), + }, + ]; + + for test_case in test_cases { + assert_eq!( + authored_ctes_response(test_case.sql), + test_case.expected_response, + "{}", + 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 e08180e949..d83ddeb0f8 100644 --- a/crates/sqlbuild-analysis/src/compiler/tests/test_types.rs +++ b/crates/sqlbuild-analysis/src/compiler/tests/test_types.rs @@ -150,3 +150,18 @@ pub(crate) struct UnresolvedReaderReferenceTestCase { pub(crate) sends_compiler_reads: bool, pub(crate) expected_error_fragments: &'static [&'static str], } + +pub(crate) struct ExtractionRuleTestCase { + pub(crate) description: &'static str, + pub(crate) mode: &'static str, + pub(crate) raw: bool, + pub(crate) syntax: &'static str, + pub(crate) sql: &'static str, + pub(crate) expected_outcome: String, +} + +pub(crate) struct AuthoredCtesTestCase { + pub(crate) description: &'static str, + pub(crate) sql: &'static str, + pub(crate) expected_response: serde_json::Value, +} diff --git a/crates/sqlbuild-analysis/src/constants.rs b/crates/sqlbuild-analysis/src/constants.rs index ff04bf7eb4..f49b9012e6 100644 --- a/crates/sqlbuild-analysis/src/constants.rs +++ b/crates/sqlbuild-analysis/src/constants.rs @@ -1,7 +1,20 @@ pub(crate) const DATETIME_TYPE: &str = "DATETIME"; +pub(crate) const DBT_REF_REFERENCE_KIND: &str = "dbt_ref"; pub(crate) const DIRECT_DEPENDENCY_PATH_LENGTH: usize = 2; pub(crate) const MACRO_TEST_MODE: &str = "macro"; pub(crate) const QUOTED_IDENTIFIER_DELIMITER_BYTES: usize = 2; +/// Keywords after which a trailing projection token is an operand, not an implicit alias. +pub(crate) const OPERAND_KEYWORDS: &[&str] = &[ + "AND", "OR", "NOT", "IS", "IN", "LIKE", "ILIKE", "SIMILAR", "BETWEEN", "CASE", "WHEN", "THEN", + "ELSE", "DISTINCT", "ALL", "ANY", "SOME", "EXISTS", "COLLATE", "ESCAPE", "INTERVAL", "ZONE", + "OVER", "AS", "FROM", +]; +/// Keywords that end an expression, so a trailing one is part of it rather than an implicit alias. +pub(crate) const VALUE_KEYWORDS: &[&str] = &["NULL", "TRUE", "FALSE", "UNKNOWN", "END"]; +/// Characters after which a trailing projection token is an operand, not an implicit alias. +pub(crate) const OPERATOR_CHARACTERS: &str = "+-*/%=<>|&^~!:.,"; +/// A projection that selects every column. +pub(crate) const SELECT_STAR_PROJECTION: &str = "*"; pub(crate) const SQL_TEST_ACTUAL_CTE: &str = "__actual"; pub(crate) const SQL_TEST_EXPECTED_CTE: &str = "__expected"; pub(crate) const SQL_TEST_ACTUAL_CTE_PREFIX: &str = "__actual__"; diff --git a/crates/sqlbuild-discovery/src/sql_tests/_helpers/test_blocks.rs b/crates/sqlbuild-discovery/src/sql_tests/_helpers/test_blocks.rs index 34c47e22fa..57211ed4f0 100644 --- a/crates/sqlbuild-discovery/src/sql_tests/_helpers/test_blocks.rs +++ b/crates/sqlbuild-discovery/src/sql_tests/_helpers/test_blocks.rs @@ -48,14 +48,17 @@ pub(crate) fn parse_sql_test_file( let header: &str = &contents[block.header_start..block.header_end]; let header_line: usize = contents[..block.header_start].matches('\n').count() + 1; match parse_statement_header(&contract, header, header_line) { - Ok(header_values) => blocks.push(SqlTestBlock { - header_values, - sql_body: python_cleandoc( - options.python, - contents[block.end.min(next_start)..next_start] - .trim_end_matches(is_python_space), - ), - }), + Ok(header_values) => { + let body_start: usize = block.end.min(next_start); + let authored: &str = + contents[body_start..next_start].trim_end_matches(is_python_space); + let span_start: usize = contents[..body_start].chars().count(); + blocks.push(SqlTestBlock { + header_values, + sql_body: python_cleandoc(options.python, authored), + body_span: (span_start, span_start + authored.chars().count()), + }); + } Err(block_failure) => { failure = Some(block_failure); break; diff --git a/crates/sqlbuild-discovery/src/sql_tests/models.rs b/crates/sqlbuild-discovery/src/sql_tests/models.rs index 7d8e607a66..9f27db23ba 100644 --- a/crates/sqlbuild-discovery/src/sql_tests/models.rs +++ b/crates/sqlbuild-discovery/src/sql_tests/models.rs @@ -18,6 +18,8 @@ pub struct SqlTestBlock { pub header_values: Vec<(String, AuthoredValue)>, /// `inspect.cleandoc` of the SQL after the header. pub sql_body: String, + /// Code-point range in the file contents of the authored text `sql_body` is cleaned from. + pub body_span: (usize, usize), } /// A test file split into blocks; parsing stops at the first block whose header fails. diff --git a/crates/sqlbuild-discovery/src/sql_tests/tests/helpers.rs b/crates/sqlbuild-discovery/src/sql_tests/tests/helpers.rs index 3bbafd4871..4a1676ae2e 100644 --- a/crates/sqlbuild-discovery/src/sql_tests/tests/helpers.rs +++ b/crates/sqlbuild-discovery/src/sql_tests/tests/helpers.rs @@ -61,6 +61,7 @@ pub(super) fn scenario_rows(test_case: &StatementFileTestCase) -> (Vec vec![block_row(&SqlTestBlock { header_values: file.header_values, sql_body: file.sql_body, + body_span: (0, 0), })], None, ) diff --git a/crates/sqlbuild-python/src/bindings/_helpers/discovery/project_files.rs b/crates/sqlbuild-python/src/bindings/_helpers/discovery/project_files.rs index b901fc4cd3..dcf64a4766 100644 --- a/crates/sqlbuild-python/src/bindings/_helpers/discovery/project_files.rs +++ b/crates/sqlbuild-python/src/bindings/_helpers/discovery/project_files.rs @@ -392,6 +392,7 @@ fn test_block_object(py: Python<'_>, block: SqlTestBlock) -> PyResult vec![ map_to_python(py, block.header_values)?, object(py, block.sql_body)?, + object(py, block.body_span)?, ], ) } diff --git a/scripts/compiler_differential/_helpers/corpus/render_failure_cases.py b/scripts/compiler_differential/_helpers/corpus/render_failure_cases.py index f74df90b55..1cfe9d0c6c 100644 --- a/scripts/compiler_differential/_helpers/corpus/render_failure_cases.py +++ b/scripts/compiler_differential/_helpers/corpus/render_failure_cases.py @@ -42,6 +42,15 @@ 'def note_refresh(ctx):\n """Note a refresh."""\n return None\n' ) _UPSTREAM_DBT_PROJECT: str = "name: upstream\nversion: '1.0.0'\nprofile: upstream\n" +_RAW_ORDERS_MOCK: str = ( + "__source__raw_orders AS (\n" + " SELECT 1 AS order_id, 10 AS customer_id, CAST(5 AS DOUBLE) AS amount," + " 'placed' AS status\n),\n" +) +_CENTS_TEST_PREFIX: str = ( + "TEST (mode macro);\n\nWITH\ninput_values AS (SELECT 5 AS amount),\n" + '__macro_actual__ AS (SELECT @cents("amount") AS cents FROM input_values),\n' +) def _staging_header(extra: str) -> dict[str, str]: @@ -327,6 +336,75 @@ def _reference_cases() -> tuple[FailureCase, ...]: ) }, ), + failure_case( + name="test-quoted-cte-name", + expected_code="P001", + expected_message='CTE name "recent orders" must be an unquoted identifier', + expected_help="rename the CTE, for example recent_orders", + files={ + "tests/unit/test_stg_orders.sql": ( + "TEST();\n\nWITH\n" + + _RAW_ORDERS_MOCK + + '"recent orders" AS (SELECT 1 AS order_id),\n' + "__expected__stg_orders AS (\n SELECT 1 AS order_id\n)\nSELECT 1\n" + ) + }, + ), + failure_case( + name="test-expected-implicit-alias", + expected_code="P001", + expected_message="must alias every non-trivial __expected__stg_orders projection", + expected_help="amount IS NULL AS ", + files={ + "tests/unit/test_stg_orders.sql": ( + "TEST();\n\nWITH\n" + + _RAW_ORDERS_MOCK + + "__expected__stg_orders AS (\n SELECT amount IS NULL\n)\nSELECT 1\n" + ) + }, + ), + failure_case( + name="test-expected-parenthesised-branch", + expected_code="P001", + expected_message=( + "must write each __expected__stg_orders set-operation branch as a plain SELECT" + ), + files={ + "tests/unit/test_stg_orders.sql": ( + "TEST();\n\nWITH\n" + _RAW_ORDERS_MOCK + "__expected__stg_orders AS (\n" + " (SELECT 1 AS order_id) UNION ALL (SELECT 2 AS order_id)\n)\nSELECT 1\n" + ) + }, + ), + failure_case( + name="macro-test-helper-calls-udf", + expected_code="P001", + expected_message=( + "mode 'macro' helper CTE 'labels' must not call udf; call reusable logic only in " + "__macro_actual__" + ), + files={ + "tests/unit/_macros/orders.py": _CENTS_MACRO, + "tests/unit/test_cents.sql": ( + _CENTS_TEST_PREFIX + 'labels AS (SELECT __udf("is_large")(5) AS is_large),\n' + "__macro_expected__ AS (SELECT 500 AS cents)\n" + ), + }, + ), + failure_case( + name="macro-test-helper-malformed-reference", + expected_code="P012", + expected_message='__source("raw", "orders") is not a valid __source() call', + expected_location=(6, 33), + files={ + "tests/unit/_macros/orders.py": _CENTS_MACRO, + "tests/unit/test_cents.sql": ( + _CENTS_TEST_PREFIX + + 'sample_orders AS (SELECT * FROM __source("raw", "orders")),\n' + "__macro_expected__ AS (SELECT 500 AS cents)\n" + ), + }, + ), failure_case( name="dbt-reference-without-manifest", expected_code="C214", diff --git a/src/sqlbuild/.agents/skills/sqlbuild/references/docs/concepts/testing.md b/src/sqlbuild/.agents/skills/sqlbuild/references/docs/concepts/testing.md index 4d31211a34..3cb54ae16e 100644 --- a/src/sqlbuild/.agents/skills/sqlbuild/references/docs/concepts/testing.md +++ b/src/sqlbuild/.agents/skills/sqlbuild/references/docs/concepts/testing.md @@ -135,8 +135,24 @@ and `//` line comments on Snowflake. An `__expected__` CTE compares only the columns it lists, matched by name, so column order does not matter and unlisted model columns are ignored. Row counts are always compared. Listing a -column the model does not output is a test error. When the expected CTE's columns are not explicit -(for example `SELECT *` from another CTE), SQLBuild compares every column by position. +column the model does not output is a test error. + +SQLBuild reads test CTEs strictly, the same way on every compiler engine, and rejects other forms +with a compile error that names the CTE: + +- A CTE name is an unquoted identifier of ASCII letters, digits and underscores that starts with a + letter or underscore. Quoted names such as `"recent orders"` or `` `recent orders` ``, and names + with `$` or non-ASCII characters, are errors that point at the name. +- `WITH`, `AS`, `SELECT` and the other keywords match ASCII letters in any case. +- Every column of an expected CTE (`__expected__`, `__macro_expected__`, `__udf_expected__` + or `__table_fn_expected__`) needs a name. A bare or qualified column reference such as `order_id` + or `o.order_id` names itself; any other expression, including `amount IS NULL` or + `CASE ... END`, needs an alias (` AS `). An alias without `AS`, such as + `1 order_id`, also names the column. +- An expected CTE is a plain `SELECT`, optionally combined with `UNION`, `INTERSECT` or `EXCEPT`. + Branches wrapped in parentheses and a `WITH` inside the CTE are errors; put shared rows in a + helper CTE instead. `SELECT *`, `SELECT DISTINCT *` and `t.*` are errors too, except + `SELECT * FROM __empty_fixture()` in a contracted `__expected__` CTE. Helper CTEs can read other helpers and mocks by CTE name, such as `__ref__stg_orders`. Shared expected rows can therefore live in one helper that both an `__expected__` CTE and an assertion @@ -605,6 +621,11 @@ Each mode has strict CTE validation: CTE prefixes from other modes are not allowed. For example, `__source__` in a macro test or `__macro_actual__` in a test declared `TEST (mode model)` will produce a clear error pointing you to the right mode. Expected CTEs must not call macros, UDFs, or table functions - they should be independent, inspectable expected data. +Helper CTEs follow the same rule in every mode: only the actual CTE calls the macro, UDF or table +function under test, so a helper of a macro test must not call `__udf()` or `__table_fn()` either. +A malformed reference call in a helper or expected CTE, such as `__source("raw", "orders")`, is a +P012 error at the call. + ## Multiple tests per file A single test file can contain multiple `TEST()` blocks. A test's name is its `name`, or the file diff --git a/src/sqlbuild/compiler/compile/_helpers/analysis/tests.py b/src/sqlbuild/compiler/compile/_helpers/analysis/tests.py deleted file mode 100644 index 9a5350e941..0000000000 --- a/src/sqlbuild/compiler/compile/_helpers/analysis/tests.py +++ /dev/null @@ -1,135 +0,0 @@ -"""Optional Polyglot-backed SQL-native test helpers.""" - -from __future__ import annotations - -from typing import Any - -from sqlbuild.compiler.compile.constants import ( - POLYGLOT_COLUMN_EXPRESSION_NAME, - POLYGLOT_SELECT_EXPRESSION_NAME, - POLYGLOT_SET_OPERATION_EXPRESSION_NAMES, - POLYGLOT_WRAPPER_EXPRESSION_NAMES, -) -from sqlbuild.compiler.compile.exceptions import CompileInputError -from sqlbuild.compiler.sql_analysis.main._split_set_operation_branches import ( - split_set_operation_branches, -) -from sqlbuild.compiler.sql_analysis.main.import_polyglot_sql import import_polyglot_sql -from sqlbuild.compiler.sql_analysis.models import SqlLexicalSyntax - -_POLYGLOT_VALUES_EXPRESSION_NAME: str = "Values" -_POLYGLOT_VALUES_SET_ALIAS: str = "_values" -_SCAN_CONTEXT: str = "SQL test" - - -def extract_expected_branch_column_names_with_sql_analysis( - *, - sql: str, - file_label: str, - syntax: SqlLexicalSyntax, - label: str = "__expected__", -) -> tuple[tuple[str, ...], ...] | None: - """Return expected SELECT branch names using required Polyglot analysis.""" - - polyglot_module: Any = import_polyglot_sql() - - try: - parsed_expression: Any = polyglot_module.parse_one(sql, dialect="generic") - except polyglot_module.PolyglotError: - return _extract_unparsed_branch_names( - sql=sql, - polyglot_module=polyglot_module, - file_label=file_label, - label=label, - syntax=syntax, - ) - return _extract_branch_names(expression=parsed_expression, file_label=file_label, label=label) - - -def _extract_unparsed_branch_names( - *, sql: str, polyglot_module: Any, file_label: str, label: str, syntax: SqlLexicalSyntax -) -> tuple[tuple[str, ...], ...] | None: - """Parse each top-level set-operation branch separately when the whole query cannot parse.""" - - branches: tuple[str, ...] = split_set_operation_branches( - sql=sql, context=_SCAN_CONTEXT, syntax=syntax - ) - if len(branches) <= 1: - return None - try: - parsed_branches: tuple[Any, ...] = tuple( - polyglot_module.parse_one(branch_sql, dialect="generic") for branch_sql in branches - ) - except polyglot_module.PolyglotError: - return None - return tuple( - _extract_branch_names(expression=expression, file_label=file_label, label=label)[0] - for expression in parsed_branches - ) - - -def _extract_branch_names( - *, expression: Any, file_label: str, label: str -) -> tuple[tuple[str, ...], ...]: - expression = _unwrap_expression(expression=expression) - if expression.__class__.__name__ in POLYGLOT_SET_OPERATION_EXPRESSION_NAMES: - left_expression: Any = expression.args["left"] - right_expression: Any = expression.args["right"] - return ( - *_extract_branch_names(expression=left_expression, file_label=file_label, label=label), - *_extract_branch_names(expression=right_expression, file_label=file_label, label=label), - ) - if expression.__class__.__name__ == POLYGLOT_SELECT_EXPRESSION_NAME: - if _is_synthetic_values_set_branch(expression): - raise CompileInputError( - f"SQL test '{file_label}' must define each {label} set-operation " - "branch as a SELECT query" - ) - return (_extract_select_names(expression=expression, file_label=file_label, label=label),) - raise CompileInputError( - f"SQL test '{file_label}' must define each {label} set-operation branch as a SELECT query" - ) - - -def _is_synthetic_values_set_branch(expression: Any) -> bool: - """Recognize Polyglot's SELECT wrapper around a VALUES set operand.""" - - projections: tuple[Any, ...] = tuple(expression.expressions) - if len(projections) != 1 or not projections[0].is_star: - return False - values_expression: Any | None = expression.find(_POLYGLOT_VALUES_EXPRESSION_NAME) - if values_expression is None: - return False - alias: object = values_expression.args.get("alias") - return isinstance(alias, dict) and alias.get("name") == _POLYGLOT_VALUES_SET_ALIAS - - -def _unwrap_expression(*, expression: Any) -> Any: - while expression.__class__.__name__ in POLYGLOT_WRAPPER_EXPRESSION_NAMES: - expression = expression.this - return expression - - -def _extract_select_names(*, expression: Any, file_label: str, label: str) -> tuple[str, ...]: - names: list[str] = [] - projection: Any - for projection in expression.expressions: - if projection.is_star: - raise CompileInputError( - f"SQL test '{file_label}' must not use SELECT * in {label} CTEs" - ) - alias_name: str = str(projection.alias or "") - if alias_name: - names.append(alias_name) - continue - if projection.__class__.__name__ == POLYGLOT_COLUMN_EXPRESSION_NAME: - names.append(str(projection.name)) - continue - raise CompileInputError( - f"SQL test '{file_label}' must alias every non-trivial {label} projection" - ) - if not names: - raise CompileInputError( - f"SQL test '{file_label}' must project at least one column in {label}" - ) - return tuple(names) diff --git a/src/sqlbuild/compiler/compile/_helpers/attachment/declaration_scope.py b/src/sqlbuild/compiler/compile/_helpers/attachment/declaration_scope.py index 5e510999bd..84b8dbc1cc 100644 --- a/src/sqlbuild/compiler/compile/_helpers/attachment/declaration_scope.py +++ b/src/sqlbuild/compiler/compile/_helpers/attachment/declaration_scope.py @@ -15,6 +15,9 @@ from sqlbuild.compiler.compile._helpers.render.declarations import ( build_declaration_scope_resolver, ) +from sqlbuild.compiler.compile._helpers.sql_tests.extraction_errors import ( + validate_authored_test_cte_names, +) from sqlbuild.compiler.compile.exceptions import CompileInputError from sqlbuild.compiler.compile.models import ( DeclarationScopeBuild, @@ -43,6 +46,9 @@ def build_declaration_scope( ) -> DeclarationScopeBuild: """Build one canonical index and validate it before SQL expansion.""" + validate_authored_test_cte_names( + test_files=discovered_inputs.test_files, syntax=sql_lexical_syntax + ) if native_stage_enabled(NativeStage.DECLARATION_SCOPES): native_scope: DeclarationScopeBuild | None = _build_native_declaration_scope( discovered_inputs=discovered_inputs, diff --git a/src/sqlbuild/compiler/compile/_helpers/attachment/sql_tests.py b/src/sqlbuild/compiler/compile/_helpers/attachment/sql_tests.py index ca3fd36208..b143019c3b 100644 --- a/src/sqlbuild/compiler/compile/_helpers/attachment/sql_tests.py +++ b/src/sqlbuild/compiler/compile/_helpers/attachment/sql_tests.py @@ -35,7 +35,10 @@ from sqlbuild.compiler.compile._helpers.sql_tests.core import ( complete_omitted_ceremonial_select, extract_assertion_target_model_names, - extract_sql_test_ctes, +) +from sqlbuild.compiler.compile._helpers.sql_tests.extraction_errors import ( + located_extraction_error, + report_authored_invalid_calls, ) from sqlbuild.compiler.compile._helpers.sql_tests.helper_ctes import ( report_test_without_target_model, @@ -44,8 +47,9 @@ ) from sqlbuild.compiler.compile._helpers.sql_tests.native import ( extract_expanded_sql_tests_cached, + extract_unexpanded_sql_test, ) -from sqlbuild.compiler.compile.exceptions import CompileInputError +from sqlbuild.compiler.compile.exceptions import CompileInputError, SqlTestExtractionError from sqlbuild.compiler.compile.models import ( AuthoredSqlExpansionResult, CompileDirectLogicSqlTestCtes, @@ -245,7 +249,8 @@ def build_test_inputs( tested_resource_names: tuple[str, ...] = () if test_mode in {SqlTestMode.MACRO, SqlTestMode.UDF, SqlTestMode.TABLE_FN}: raw_test_ctes: CompileSqlTestCtes = _validate_raw_direct_logic_test_ctes( - test_block=expanded_test_block, + sql=expanded_test_block.sql_body, + test_block=test_block, test_file=test_file, test_mode=test_mode, syntax=sql_lexical_syntax, @@ -302,13 +307,23 @@ def build_test_inputs( test_case=test_case, ) ) - test_ctes_batch: tuple[CompileSqlTestCtes, ...] = extract_expanded_sql_tests_cached( - tests=tuple( - (test.sql_body, str(test.test_file.relative_path), test.mode) for test in expanded_tests - ), - cache_root=compile_cache_dir, - syntax=sql_lexical_syntax, - ) + try: + test_ctes_batch: tuple[CompileSqlTestCtes, ...] = extract_expanded_sql_tests_cached( + tests=tuple( + (test.sql_body, str(test.test_file.relative_path), test.mode) + for test in expanded_tests + ), + cache_root=compile_cache_dir, + syntax=sql_lexical_syntax, + ) + except SqlTestExtractionError as error: + failed: _ExpandedSqlTest = expanded_tests[error.test_index] + raise located_extraction_error( + error=error, + test_file=failed.test_file, + test_block=failed.test_block, + sql=failed.sql_body, + ) from None target_catalog: _native.SqlTestTargetCatalog | None = ( native_test_target_catalog( models=known_model_names, @@ -480,17 +495,34 @@ def _build_test_input_payload( def _validate_raw_direct_logic_test_ctes( *, + sql: str, test_block: DiscoveredSqlTestBlock, test_file: DiscoveredSqlTestFile, test_mode: SqlTestMode, syntax: SqlLexicalSyntax, ) -> CompileSqlTestCtes: - return extract_sql_test_ctes( - sql=test_block.sql_body, - file_label=str(test_file.relative_path), + try: + test_ctes, invalid_calls = extract_unexpanded_sql_test( + sql=sql, file_label=str(test_file.relative_path), mode=test_mode, syntax=syntax + ) + except SqlTestExtractionError as error: + raise located_extraction_error( + error=error, test_file=test_file, test_block=test_block, sql=sql + ) from None + if not (invalid_calls and isinstance(test_ctes.payload, CompileDirectLogicSqlTestCtes)): + return test_ctes + reported: int = report_authored_invalid_calls( + test_file=test_file, + test_block=test_block, + actual_cte_name=test_ctes.payload.actual_cte.name, syntax=syntax, - mode=test_mode, ) + if reported == 0: + for cte in (*test_ctes.payload.helper_ctes, test_ctes.payload.expected_cte): + _ = extract_sql_references( + sql=cte.sql_body, syntax=syntax, origin=_sql_file_reference_origin(test_file) + ) + return test_ctes def _infer_tested_direct_logic_resource_names( diff --git a/src/sqlbuild/compiler/compile/_helpers/sql_tests/core.py b/src/sqlbuild/compiler/compile/_helpers/sql_tests/core.py index 4ec560a073..fe99c6d313 100644 --- a/src/sqlbuild/compiler/compile/_helpers/sql_tests/core.py +++ b/src/sqlbuild/compiler/compile/_helpers/sql_tests/core.py @@ -3,56 +3,29 @@ from __future__ import annotations from collections.abc import Callable -from dataclasses import dataclass from functools import lru_cache -from pathlib import Path from typing import Any import sqlbuild._native as _native from sqlbuild.compiler.compile._helpers.analysis.ctes import ( extract_top_level_ctes_with_sql_analysis, ) -from sqlbuild.compiler.compile._helpers.analysis.tests import ( - extract_expected_branch_column_names_with_sql_analysis, -) from sqlbuild.compiler.compile._helpers.refs.references import extract_sql_references -from sqlbuild.compiler.compile._helpers.render.macros import find_macro_call_names -from sqlbuild.compiler.compile._helpers.sql_tests.empty_fixture import is_empty_fixture_query from sqlbuild.compiler.compile.constants import ( - ASSERT_TEST_CTE_PREFIX, - DBT_REF_TEST_CTE_PREFIX, DEFAULT_SQL_TEST_MODE, EXPECTED_TEST_CTE_PREFIX, - MACRO_ACTUAL_TEST_CTE_NAME, - MACRO_EXPECTED_TEST_CTE_NAME, - MACRO_TEST_CTE_PREFIX, OMITTED_CEREMONIAL_SELECT_SQL, - REF_TEST_CTE_PREFIX, - RESERVED_SQL_TEST_CTE_NAMES, - SEED_TEST_CTE_PREFIX, - SOURCE_TEST_CTE_PREFIX, SQL_ARGUMENT_SEPARATOR_TOKEN, SQL_CEREMONIAL_SELECT_VALUE, SQL_OPEN_PAREN_TOKEN, SQL_QUOTE_TOKENS, - SQL_SINGLE_QUOTE_TOKEN, SQL_STATEMENT_TERMINATOR_TOKEN, - SQL_WILDCARD_TOKEN, SQL_WITH_KEYWORD, - TABLE_FN_ACTUAL_TEST_CTE_NAME, - TABLE_FN_EXPECTED_TEST_CTE_NAME, - TABLE_FN_TEST_CTE_PREFIX, - UDF_ACTUAL_TEST_CTE_NAME, - UDF_EXPECTED_TEST_CTE_NAME, ) from sqlbuild.compiler.compile.exceptions import CompileInputError from sqlbuild.compiler.compile.models import ( - CompileDirectLogicSqlTestCtes, - CompileModelSqlTestCtes, - CompileSqlReference, CompileSqlScenarioCte, CompileSqlTestCte, - CompileSqlTestCtes, SqlReferenceOrigin, ) from sqlbuild.compiler.compile.types import SqlTestMode @@ -67,11 +40,7 @@ is_identifier_character, ) from sqlbuild.compiler.sql_analysis.main._is_identifier_start import is_identifier_start -from sqlbuild.compiler.sql_analysis.main._iter_code_positions import iter_code_positions from sqlbuild.compiler.sql_analysis.main._skip_dialect_non_code import dialect_non_code_end -from sqlbuild.compiler.sql_analysis.main._split_set_operation_branches import ( - split_set_operation_branches, -) from sqlbuild.compiler.sql_analysis.main.import_polyglot_sql import import_polyglot_sql from sqlbuild.compiler.sql_analysis.models import SqlLexicalSyntax @@ -81,66 +50,6 @@ _SQL_IDENTIFIER_QUOTE_TOKENS: frozenset[str] = frozenset({'"', "`"}) -@dataclass(frozen=True) -class _DirectLogicModeSpec: - mode: SqlTestMode - actual_cte_name: str - expected_cte_name: str - foreign_ctes: tuple[tuple[frozenset[str], str], ...] - - -_MACRO_TEST_CTE_NAMES: frozenset[str] = frozenset( - {MACRO_ACTUAL_TEST_CTE_NAME, MACRO_EXPECTED_TEST_CTE_NAME} -) -_UDF_TEST_CTE_NAMES: frozenset[str] = frozenset( - {UDF_ACTUAL_TEST_CTE_NAME, UDF_EXPECTED_TEST_CTE_NAME} -) -_TABLE_FN_TEST_CTE_NAMES: frozenset[str] = frozenset( - {TABLE_FN_ACTUAL_TEST_CTE_NAME, TABLE_FN_EXPECTED_TEST_CTE_NAME} -) -_DIRECT_LOGIC_MODE_SPECS: dict[SqlTestMode, _DirectLogicModeSpec] = { - SqlTestMode.MACRO: _DirectLogicModeSpec( - mode=SqlTestMode.MACRO, - actual_cte_name=MACRO_ACTUAL_TEST_CTE_NAME, - expected_cte_name=MACRO_EXPECTED_TEST_CTE_NAME, - foreign_ctes=( - (_UDF_TEST_CTE_NAMES, "UDF-test"), - (_TABLE_FN_TEST_CTE_NAMES, "table_fn-test"), - ), - ), - SqlTestMode.UDF: _DirectLogicModeSpec( - mode=SqlTestMode.UDF, - actual_cte_name=UDF_ACTUAL_TEST_CTE_NAME, - expected_cte_name=UDF_EXPECTED_TEST_CTE_NAME, - foreign_ctes=( - (_MACRO_TEST_CTE_NAMES, "macro-test"), - (_TABLE_FN_TEST_CTE_NAMES, "table_fn-test"), - ), - ), - SqlTestMode.TABLE_FN: _DirectLogicModeSpec( - mode=SqlTestMode.TABLE_FN, - actual_cte_name=TABLE_FN_ACTUAL_TEST_CTE_NAME, - expected_cte_name=TABLE_FN_EXPECTED_TEST_CTE_NAME, - foreign_ctes=((_MACRO_TEST_CTE_NAMES | _UDF_TEST_CTE_NAMES, "another direct-logic"),), - ), -} - - -def extract_sql_test_ctes( - *, - sql: str, - file_label: str, - syntax: SqlLexicalSyntax, - mode: SqlTestMode = DEFAULT_SQL_TEST_MODE, -) -> CompileSqlTestCtes: - """Extract top-level SQL-native test mock and expected CTEs.""" - - ctes: tuple[CompileSqlTestCte, ...] = extract_unclassified_sql_test_ctes( - sql=sql, file_label=file_label, syntax=syntax - ) - return classify_sql_test_ctes(ctes=ctes, file_label=file_label, mode=mode, syntax=syntax) - - def extract_unclassified_sql_test_ctes( *, sql: str, file_label: str, syntax: SqlLexicalSyntax ) -> tuple[CompileSqlTestCte, ...]: @@ -167,18 +76,6 @@ def extract_unclassified_sql_test_ctes( return ctes -def classify_sql_test_ctes( - *, - ctes: tuple[CompileSqlTestCte, ...], - file_label: str, - mode: SqlTestMode, - syntax: SqlLexicalSyntax, -) -> CompileSqlTestCtes: - """Apply mode-specific validation to extracted SQL test CTEs.""" - - return _classify_sql_test_ctes(ctes=ctes, file_label=file_label, mode=mode, syntax=syntax) - - def extract_sql_test_expected_model_names( *, sql: str, @@ -359,588 +256,6 @@ def _scan_top_level_ctes( return tuple(ctes), index -def _classify_sql_test_ctes( - *, - ctes: tuple[CompileSqlTestCte, ...], - file_label: str, - mode: SqlTestMode, - syntax: SqlLexicalSyntax, -) -> CompileSqlTestCtes: - match mode: - case SqlTestMode.MODEL: - return _classify_model_sql_test_ctes(ctes=ctes, file_label=file_label, syntax=syntax) - case SqlTestMode.MACRO | SqlTestMode.UDF | SqlTestMode.TABLE_FN: - return _classify_direct_logic_sql_test_ctes( - ctes=ctes, - file_label=file_label, - spec=_DIRECT_LOGIC_MODE_SPECS[mode], - syntax=syntax, - ) - case _: - raise CompileInputError(f"SQL test '{file_label}' has unsupported mode '{mode}'") - - -def _classify_direct_logic_sql_test_ctes( - *, - ctes: tuple[CompileSqlTestCte, ...], - file_label: str, - spec: _DirectLogicModeSpec, - syntax: SqlLexicalSyntax, -) -> CompileSqlTestCtes: - mode: str = spec.mode.value - authored_ctes: list[CompileSqlTestCte] = [] - actual_cte: CompileSqlTestCte | None = None - expected_cte: CompileSqlTestCte | None = None - - cte: CompileSqlTestCte - for cte in ctes: - if cte.name == spec.actual_cte_name: - if actual_cte is not None: - raise CompileInputError( - f"SQL test '{file_label}' mode '{mode}' must define exactly one " - f"{spec.actual_cte_name} CTE" - ) - actual_cte = cte - continue - if cte.name == spec.expected_cte_name: - if expected_cte is not None: - raise CompileInputError( - f"SQL test '{file_label}' mode '{mode}' must define exactly one " - f"{spec.expected_cte_name} CTE" - ) - _validate_expected_cte_query( - cte=cte, file_label=file_label, label=cte.name, syntax=syntax - ) - expected_cte = cte - continue - if _is_model_mode_cte(cte.name): - raise CompileInputError( - f"SQL test '{file_label}' is mode '{mode}' but defines model-test CTE '{cte.name}'" - ) - foreign_names: frozenset[str] - foreign_label: str - for foreign_names, foreign_label in spec.foreign_ctes: - if cte.name in foreign_names: - raise CompileInputError( - f"SQL test '{file_label}' is mode '{mode}' but defines {foreign_label} CTE " - f"'{cte.name}'" - ) - if cte.name in RESERVED_SQL_TEST_CTE_NAMES: - raise CompileInputError( - f"SQL test '{file_label}' uses reserved helper CTE name '{cte.name}'" - ) - authored_ctes.append(cte) - - if actual_cte is None or expected_cte is None: - raise CompileInputError( - f"SQL test '{file_label}' mode '{mode}' must define exactly one " - f"{spec.actual_cte_name} CTE and exactly one " - f"{spec.expected_cte_name} CTE" - ) - if spec.mode is SqlTestMode.MACRO: - _validate_macro_test_bodies( - helper_ctes=tuple(authored_ctes), expected_cte=expected_cte, file_label=file_label - ) - else: - _validate_call_free_direct_logic_bodies( - helper_ctes=tuple(authored_ctes), - expected_cte=expected_cte, - file_label=file_label, - mode=spec.mode, - actual_cte_name=spec.actual_cte_name, - syntax=syntax, - ) - return CompileSqlTestCtes( - mode=spec.mode, - payload=CompileDirectLogicSqlTestCtes( - mode=spec.mode, - helper_ctes=tuple(authored_ctes), - actual_cte=actual_cte, - expected_cte=expected_cte, - ), - ) - - -def _classify_model_sql_test_ctes( - *, ctes: tuple[CompileSqlTestCte, ...], file_label: str, syntax: SqlLexicalSyntax -) -> CompileSqlTestCtes: - validate_independent_expected_and_assertion_ctes( - ctes=ctes, - expected_prefix=EXPECTED_TEST_CTE_PREFIX, - assertion_prefix=ASSERT_TEST_CTE_PREFIX, - file_label=file_label, - context_label="SQL test", - syntax=syntax, - ) - authored_ctes: list[CompileSqlTestCte] = [] - macro_mocks: dict[str, str] = {} - mock_model_names: list[str] = [] - mock_source_names: list[str] = [] - mock_seed_names: list[str] = [] - mock_dbt_ref_names: list[str] = [] - mock_table_function_names: list[str] = [] - expected_ctes: list[CompileSqlTestCte] = [] - expected_model_names: list[str] = [] - assertion_ctes: list[CompileSqlTestCte] = [] - assertion_names: list[str] = [] - - cte: CompileSqlTestCte - for cte in ctes: - if cte.name in {MACRO_ACTUAL_TEST_CTE_NAME, MACRO_EXPECTED_TEST_CTE_NAME}: - raise CompileInputError( - f"SQL test '{file_label}' is mode '{DEFAULT_SQL_TEST_MODE.value}' but defines " - f"macro-test CTE '{cte.name}'; use TEST (mode {SqlTestMode.MACRO.value})" - ) - if cte.name in {UDF_ACTUAL_TEST_CTE_NAME, UDF_EXPECTED_TEST_CTE_NAME}: - raise CompileInputError( - f"SQL test '{file_label}' is mode '{DEFAULT_SQL_TEST_MODE.value}' but defines " - f"UDF-test CTE '{cte.name}'; use TEST (mode {SqlTestMode.UDF.value})" - ) - if cte.name in {TABLE_FN_ACTUAL_TEST_CTE_NAME, TABLE_FN_EXPECTED_TEST_CTE_NAME}: - raise CompileInputError( - f"SQL test '{file_label}' is mode '{DEFAULT_SQL_TEST_MODE.value}' but defines " - f"table_fn-test CTE '{cte.name}'; use TEST (mode {SqlTestMode.TABLE_FN.value})" - ) - if cte.name.startswith(MACRO_TEST_CTE_PREFIX): - macro_name: str = _require_prefixed_name( - cte_name=cte.name, - prefix=MACRO_TEST_CTE_PREFIX, - label="__macro__", - file_label=file_label, - ) - macro_mocks[macro_name] = _extract_macro_mock_value( - cte=cte, file_label=file_label, syntax=syntax - ) - continue - if cte.name.startswith(REF_TEST_CTE_PREFIX): - mock_model_names.append( - _require_prefixed_name( - cte_name=cte.name, - prefix=REF_TEST_CTE_PREFIX, - label="__ref__", - file_label=file_label, - ) - ) - authored_ctes.append(cte) - continue - if cte.name.startswith(SOURCE_TEST_CTE_PREFIX): - mock_source_names.append( - _require_prefixed_name( - cte_name=cte.name, - prefix=SOURCE_TEST_CTE_PREFIX, - label="__source__", - file_label=file_label, - ) - ) - authored_ctes.append(cte) - continue - if cte.name.startswith(SEED_TEST_CTE_PREFIX): - mock_seed_names.append( - _require_prefixed_name( - cte_name=cte.name, - prefix=SEED_TEST_CTE_PREFIX, - label="__seed__", - file_label=file_label, - ) - ) - authored_ctes.append(cte) - continue - if cte.name.startswith(DBT_REF_TEST_CTE_PREFIX): - mock_dbt_ref_names.append( - _require_prefixed_name( - cte_name=cte.name, - prefix=DBT_REF_TEST_CTE_PREFIX, - label="__dbt_ref__ or __dbt_ref____", - file_label=file_label, - ) - ) - authored_ctes.append(cte) - continue - if cte.name.startswith(TABLE_FN_TEST_CTE_PREFIX): - mock_table_function_names.append( - _require_prefixed_name( - cte_name=cte.name, - prefix=TABLE_FN_TEST_CTE_PREFIX, - label="__table_fn__", - file_label=file_label, - ) - ) - authored_ctes.append(cte) - continue - if cte.name.startswith(EXPECTED_TEST_CTE_PREFIX): - expected_model_names.append( - _require_prefixed_name( - cte_name=cte.name, - prefix=EXPECTED_TEST_CTE_PREFIX, - label="__expected__", - file_label=file_label, - ) - ) - _validate_expected_cte_query( - cte=cte, - file_label=file_label, - label=cte.name, - syntax=syntax, - allow_empty_fixture=True, - ) - expected_ctes.append(cte) - continue - if cte.name.startswith(ASSERT_TEST_CTE_PREFIX): - assertion_names.append( - _require_prefixed_name( - cte_name=cte.name, - prefix=ASSERT_TEST_CTE_PREFIX, - label="__assert__", - file_label=file_label, - ) - ) - assertion_ctes.append(cte) - continue - if cte.name in RESERVED_SQL_TEST_CTE_NAMES: - raise CompileInputError( - f"SQL test '{file_label}' uses reserved helper CTE name '{cte.name}'" - ) - authored_ctes.append(cte) - - mock_target_names: tuple[str, ...] = ( - *mock_model_names, - *mock_source_names, - *mock_seed_names, - *mock_dbt_ref_names, - *mock_table_function_names, - ) - if not mock_target_names: - raise CompileInputError( - f"SQL test '{file_label}' must define at least one __ref__*, __source__*, " - "__seed__*, __dbt_ref__*, or __table_fn__* mock CTE" - ) - check_names: tuple[str, ...] = (*expected_model_names, *assertion_names) - if not check_names: - raise CompileInputError( - f"SQL test '{file_label}' must define at least one __expected__ or " - "__assert__ CTE" - ) - model_payload: CompileModelSqlTestCtes = CompileModelSqlTestCtes( - authored_ctes=tuple(authored_ctes), - macro_mocks=macro_mocks, - mock_model_names=tuple(mock_model_names), - mock_source_names=tuple(mock_source_names), - mock_seed_names=tuple(mock_seed_names), - mock_dbt_ref_names=tuple(mock_dbt_ref_names), - mock_table_function_names=tuple(mock_table_function_names), - expected_ctes=tuple(expected_ctes), - expected_model_names=tuple(expected_model_names), - assertion_ctes=tuple(assertion_ctes), - assertion_names=tuple(assertion_names), - ) - return CompileSqlTestCtes(mode=SqlTestMode.MODEL, payload=model_payload) - - -def _is_model_mode_cte(cte_name: str) -> bool: - return cte_name.startswith( - ( - MACRO_TEST_CTE_PREFIX, - REF_TEST_CTE_PREFIX, - SOURCE_TEST_CTE_PREFIX, - SEED_TEST_CTE_PREFIX, - DBT_REF_TEST_CTE_PREFIX, - TABLE_FN_TEST_CTE_PREFIX, - EXPECTED_TEST_CTE_PREFIX, - ASSERT_TEST_CTE_PREFIX, - ) - ) - - -def _validate_macro_test_bodies( - *, - helper_ctes: tuple[CompileSqlTestCte, ...], - expected_cte: CompileSqlTestCte, - file_label: str, -) -> None: - helper_cte: CompileSqlTestCte - for helper_cte in helper_ctes: - macro_names: tuple[str, ...] = find_macro_call_names(helper_cte.sql_body) - if macro_names: - raise CompileInputError( - f"SQL test '{file_label}' mode 'macro' helper CTE '{helper_cte.name}' " - "must not call macros; call macros only in __macro_actual__" - ) - expected_macro_names: tuple[str, ...] = find_macro_call_names(expected_cte.sql_body) - if expected_macro_names: - raise CompileInputError( - f"SQL test '{file_label}' mode 'macro' CTE {MACRO_EXPECTED_TEST_CTE_NAME} " - "must not call macros" - ) - - -def _validate_call_free_direct_logic_bodies( - *, - helper_ctes: tuple[CompileSqlTestCte, ...], - expected_cte: CompileSqlTestCte, - file_label: str, - mode: SqlTestMode, - actual_cte_name: str, - syntax: SqlLexicalSyntax, -) -> None: - helper_cte: CompileSqlTestCte - for helper_cte in helper_ctes: - _validate_no_direct_logic_calls( - sql=helper_cte.sql_body, - file_label=file_label, - mode=mode, - cte_label=f"helper CTE '{helper_cte.name}'", - allowed_location=actual_cte_name, - syntax=syntax, - ) - _validate_no_direct_logic_calls( - sql=expected_cte.sql_body, - file_label=file_label, - mode=mode, - cte_label=f"CTE {expected_cte.name}", - allowed_location=actual_cte_name, - syntax=syntax, - ) - - -def _validate_no_direct_logic_calls( - *, - sql: str, - file_label: str, - mode: SqlTestMode, - cte_label: str, - allowed_location: str, - syntax: SqlLexicalSyntax, -) -> None: - macro_names: tuple[str, ...] = find_macro_call_names(sql) - if macro_names: - raise CompileInputError( - f"SQL test '{file_label}' mode '{mode.value}' {cte_label} must not call macros" - ) - references: tuple[CompileSqlReference, ...] = extract_sql_references( - sql=sql, - syntax=syntax, - origin=SqlReferenceOrigin( - file_path=Path(file_label), relative_path=Path(file_label), contents="" - ), - ) - reference: CompileSqlReference | None = next( - ( - item - for item in references - if item.ref_kind in {SqlReferenceKind.UDF, SqlReferenceKind.TABLE_FUNCTION} - ), - None, - ) - if reference is not None: - reference_kind: SqlReferenceKind = SqlReferenceKind(reference.ref_kind) - raise CompileInputError( - f"SQL test '{file_label}' mode '{mode.value}' {cte_label} must not call " - f"{reference_kind.value}; call reusable logic only in {allowed_location}" - ) - - -def _extract_macro_mock_value( - *, cte: CompileSqlTestCte, file_label: str, syntax: SqlLexicalSyntax -) -> str: - """Extract the single SQL string literal value from a __macro__ CTE.""" - - body: str = cte.sql_body.strip() - index: int = _skip_ignorable(sql=body, start=0, syntax=syntax) - index = _consume_keyword(sql=body, start=index, keyword="SELECT", file_label=file_label) - index = _skip_ignorable(sql=body, start=index, syntax=syntax) - if index >= len(body) or body[index] != SQL_SINGLE_QUOTE_TOKEN: - raise CompileInputError( - f"SQL test '{file_label}' macro mock '{cte.name}' must be a single SELECT string " - "literal, for example SELECT '''US'''" - ) - value: str - value, index = _read_sql_string_literal(sql=body, start=index) - index = _skip_ignorable(sql=body, start=index, syntax=syntax) - if index < len(body) and body[index] == SQL_STATEMENT_TERMINATOR_TOKEN: - index = _skip_ignorable(sql=body, start=index + 1, syntax=syntax) - if index != len(body): - raise CompileInputError( - f"SQL test '{file_label}' macro mock '{cte.name}' must be a single SELECT string " - "literal with no FROM, UNION, or additional columns" - ) - return value - - -def _read_sql_string_literal(*, sql: str, start: int) -> tuple[str, int]: - """Read one single-quoted SQL string literal and unescape doubled quotes.""" - - value_parts: list[str] = [] - index: int = start + 1 - while index < len(sql): - char: str = sql[index] - if char == SQL_SINGLE_QUOTE_TOKEN: - if index + 1 < len(sql) and sql[index + 1] == SQL_SINGLE_QUOTE_TOKEN: - value_parts.append("'") - index += 2 - continue - return "".join(value_parts), index + 1 - value_parts.append(char) - index += 1 - raise CompileInputError("SQL test macro mock has an unterminated string literal") - - -def _validate_expected_cte_query( - *, - cte: CompileSqlTestCte, - file_label: str, - syntax: SqlLexicalSyntax, - label: str = "__expected__", - allow_empty_fixture: bool = False, -) -> None: - if allow_empty_fixture and is_empty_fixture_query(cte.sql_body): - return - if _contains_select_star(sql=cte.sql_body, syntax=syntax): - raise CompileInputError(f"SQL test '{file_label}' must not use SELECT * in {label} CTEs") - branch_column_names: tuple[tuple[str, ...], ...] = _extract_expected_branch_column_names( - sql=cte.sql_body, - file_label=file_label, - label=label, - syntax=syntax, - ) - first_branch_column_names: tuple[str, ...] = branch_column_names[0] - branch_index: int - for branch_index, column_names in enumerate(branch_column_names[1:], start=2): - if column_names != first_branch_column_names: - raise CompileInputError( - f"SQL test '{file_label}' must use the same {label} " - f"projection names and order in every set-operation branch; branch {branch_index} " - "does not match branch 1" - ) - - -def _extract_expected_branch_column_names( - *, sql: str, file_label: str, label: str, syntax: SqlLexicalSyntax -) -> tuple[tuple[str, ...], ...]: - sql_analysis_column_names: tuple[tuple[str, ...], ...] | None = ( - extract_expected_branch_column_names_with_sql_analysis( - sql=sql, file_label=file_label, label=label, syntax=syntax - ) - ) - if sql_analysis_column_names is not None: - return sql_analysis_column_names - branches: tuple[str, ...] = split_set_operation_branches( - sql=sql, context=_CONTEXT, syntax=syntax - ) - return tuple( - _extract_expected_select_column_names( - branch_sql=branch, file_label=file_label, label=label, syntax=syntax - ) - for branch in branches - ) - - -def _extract_expected_select_column_names( - *, branch_sql: str, file_label: str, label: str, syntax: SqlLexicalSyntax -) -> tuple[str, ...]: - index: int = _skip_ignorable(sql=branch_sql, start=0, syntax=syntax) - select_end: int | None = _try_consume_keyword(sql=branch_sql, start=index, keyword="SELECT") - if select_end is None: - raise CompileInputError( - f"SQL test '{file_label}' must define each {label} set-operation " - "branch as a SELECT query" - ) - select_list_end: int = _find_select_list_end(sql=branch_sql, start=select_end, syntax=syntax) - raw_select_list: str = branch_sql[select_end:select_list_end] - expressions: tuple[str, ...] = _split_top_level_commas(raw_value=raw_select_list, syntax=syntax) - if not expressions: - raise CompileInputError( - f"SQL test '{file_label}' must project at least one column in {label}" - ) - return tuple( - _extract_expected_projection_name( - expression=expression, file_label=file_label, label=label, syntax=syntax - ) - for expression in expressions - ) - - -def _find_select_list_end(*, sql: str, start: int, syntax: SqlLexicalSyntax) -> int: - index: int - depth: int - for index, depth in iter_code_positions(sql=sql[start:], context=_CONTEXT, syntax=syntax): - if ( - depth == 0 - and _try_consume_keyword(sql=sql, start=start + index, keyword="FROM") is not None - ): - return start + index - return len(sql) - - -def _split_top_level_commas(*, raw_value: str, syntax: SqlLexicalSyntax) -> tuple[str, ...]: - values: list[str] = [] - value_start: int = 0 - index: int - depth: int - for index, depth in iter_code_positions(sql=raw_value, context=_CONTEXT, syntax=syntax): - if depth == 0 and raw_value[index] == SQL_ARGUMENT_SEPARATOR_TOKEN: - item: str = raw_value[value_start:index].strip() - if item: - values.append(item) - value_start = index + 1 - - final_item: str = raw_value[value_start:].strip() - if final_item: - values.append(final_item) - return tuple(values) - - -def _extract_expected_projection_name( - *, expression: str, file_label: str, label: str, syntax: SqlLexicalSyntax -) -> str: - alias_name: str | None = _extract_as_alias(expression=expression, syntax=syntax) - if alias_name is not None: - return alias_name - start: int = _skip_ignorable(sql=expression, start=0, syntax=syntax) - if start < len(expression) and is_identifier_start(expression[start]): - name: str - end: int - name, end = _read_identifier(sql=expression, start=start, file_label="projection") - if _skip_ignorable(sql=expression, start=end, syntax=syntax) == len(expression): - return name - raise CompileInputError( - f"SQL test '{file_label}' must alias every non-trivial {label} projection" - ) - - -def _extract_as_alias(*, expression: str, syntax: SqlLexicalSyntax) -> str | None: - last_alias_name: str | None = None - index: int - depth: int - for index, depth in iter_code_positions(sql=expression, context=_CONTEXT, syntax=syntax): - if depth != 0: - continue - as_end: int | None = _try_consume_keyword(sql=expression, start=index, keyword="AS") - if as_end is None: - continue - alias_index: int = _skip_ignorable(sql=expression, start=as_end, syntax=syntax) - if alias_index < len(expression) and is_identifier_start(expression[alias_index]): - alias_name, alias_end = _read_identifier( - sql=expression, - start=alias_index, - file_label="projection", - ) - if _skip_ignorable(sql=expression, start=alias_end, syntax=syntax) == len(expression): - last_alias_name = alias_name - return last_alias_name - - -def _contains_select_star(*, sql: str, syntax: SqlLexicalSyntax) -> bool: - index: int - for index, _depth in iter_code_positions(sql=sql, context=_CONTEXT, syntax=syntax): - select_end: int | None = _try_consume_keyword(sql=sql, start=index, keyword="SELECT") - if select_end is None: - continue - value_index: int = _skip_ignorable(sql=sql, start=select_end, syntax=syntax) - if value_index < len(sql) and sql[value_index] == SQL_WILDCARD_TOKEN: - return True - return False - - def _require_prefixed_name( *, cte_name: str, prefix: str, label: str, file_label: str, context_label: str = _CONTEXT ) -> str: diff --git a/src/sqlbuild/compiler/compile/_helpers/sql_tests/empty_fixture.py b/src/sqlbuild/compiler/compile/_helpers/sql_tests/empty_fixture.py deleted file mode 100644 index 2b287d3dbd..0000000000 --- a/src/sqlbuild/compiler/compile/_helpers/sql_tests/empty_fixture.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Recognition for the first-class empty relation fixture marker.""" - -from __future__ import annotations - -import re - -_EMPTY_FIXTURE_PATTERN: re.Pattern[str] = re.compile( - r"^\s*SELECT\s+\*\s+FROM\s+__empty_fixture\s*\(\s*\)\s*$", - re.IGNORECASE, -) - - -def is_empty_fixture_query(sql: str) -> bool: - """Return whether SQL is exactly the empty relation fixture marker.""" - - return _EMPTY_FIXTURE_PATTERN.fullmatch(sql) is not None diff --git a/src/sqlbuild/compiler/compile/_helpers/sql_tests/extraction_errors.py b/src/sqlbuild/compiler/compile/_helpers/sql_tests/extraction_errors.py new file mode 100644 index 0000000000..17a8148199 --- /dev/null +++ b/src/sqlbuild/compiler/compile/_helpers/sql_tests/extraction_errors.py @@ -0,0 +1,212 @@ +"""Point SQL-test extraction errors and malformed calls at the authored text they are about.""" + +from __future__ import annotations + +import sys +from dataclasses import dataclass + +from sqlbuild.compiler.compile._helpers.diagnostics.collector import report_compile_diagnostic +from sqlbuild.compiler.compile._helpers.refs.references import ( + reference_call_location, + reference_call_syntax_key, + scan_sql_reference_calls, +) +from sqlbuild.compiler.compile._helpers.sql_tests.native import ( + authored_sql_test_cte_batch, + authored_sql_test_ctes, +) +from sqlbuild.compiler.compile.constants import REFERENCE_CALL_SYNTAX_CODE +from sqlbuild.compiler.compile.exceptions import CompileInputError, SqlTestExtractionError +from sqlbuild.compiler.compile.models import CompilerDiagnostic, SqlReferenceScan +from sqlbuild.compiler.compile.types import DiagnosticPhase, DiagnosticSeverity +from sqlbuild.compiler.discovery.models import DiscoveredSqlTestBlock, DiscoveredSqlTestFile +from sqlbuild.compiler.sql_analysis.models import SqlLexicalSyntax +from sqlbuild.spec.contracts.models import SourceLocation + +_TAB_SIZE: int = 8 +_LINE_BREAK: str = "\n" +_CARRIAGE_RETURN: str = "\r" +_TAB: str = "\t" + + +@dataclass(frozen=True) +class _CleanedLine: + """One line of `inspect.cleandoc` output and where it comes from in the authored text.""" + + authored_start: int + authored_text: str + removed_columns: int + + +def validate_authored_test_cte_names( + *, test_files: tuple[DiscoveredSqlTestFile, ...], syntax: SqlLexicalSyntax +) -> None: + """Raise the located CTE-name error of the first authored test block that has one.""" + + blocks: list[tuple[DiscoveredSqlTestFile, DiscoveredSqlTestBlock]] = [] + test_file: DiscoveredSqlTestFile + for test_file in test_files: + blocks.extend((test_file, test_block) for test_block in test_file.blocks) + try: + _ = authored_sql_test_cte_batch( + tests=tuple( + (test_block.sql_body, str(test_file.relative_path)) + for test_file, test_block in blocks + ), + syntax=syntax, + ) + except SqlTestExtractionError as error: + failed_file, failed_block = blocks[error.test_index] + raise located_extraction_error( + error=error, test_file=failed_file, test_block=failed_block, sql=failed_block.sql_body + ) from None + + +def located_extraction_error( + *, + error: SqlTestExtractionError, + test_file: DiscoveredSqlTestFile, + test_block: DiscoveredSqlTestBlock, + sql: str, +) -> CompileInputError: + """Return the error at the file line and column of its offending text when that is exact.""" + + offset: int | None = ( + authored_file_offset( + test_file=test_file, test_block=test_block, body_offset=error.token_offset + ) + if error.token_offset is not None and sql == test_block.sql_body + else None + ) + if offset is None: + return CompileInputError(error.message, code=error.code, help=error.help) + contents: str = test_file.contents + line: int = contents.count(_LINE_BREAK, 0, offset) + 1 + column: int = offset - (contents.rfind(_LINE_BREAK, 0, offset) + 1) + 1 + return CompileInputError( + f"{error.message}\n --> {test_file.relative_path.as_posix()}:{line}:{column}", + code=error.code, + help=error.help, + ) + + +def report_authored_invalid_calls( + *, + test_file: DiscoveredSqlTestFile, + test_block: DiscoveredSqlTestBlock, + actual_cte_name: str, + syntax: SqlLexicalSyntax, +) -> int: + """Report each malformed reference call outside the actual CTE at its own location.""" + + ctes: tuple[tuple[str, int, str], ...] = authored_sql_test_ctes( + sql=test_block.sql_body, file_label=str(test_file.relative_path), syntax=syntax + ) + reported: int = 0 + name: str + body_start: int + body: str + for name, body_start, body in ctes: + if name == actual_cte_name: + continue + scan: SqlReferenceScan = scan_sql_reference_calls(sql=body, syntax=syntax) + for invalid_call in scan.invalid_calls: + offset: int | None = authored_file_offset( + test_file=test_file, + test_block=test_block, + body_offset=body_start + invalid_call.start, + ) + if offset is None: + return reported + location: SourceLocation = reference_call_location( + path=test_file.relative_path, + text=test_file.contents, + start=offset, + call=invalid_call.call, + ) + report_compile_diagnostic( + key=reference_call_syntax_key( + file_path=test_file.file_path, call=invalid_call.call, location=location + ), + diagnostic=CompilerDiagnostic( + phase=DiagnosticPhase.COMPILE, + severity=DiagnosticSeverity.ERROR, + code=REFERENCE_CALL_SYNTAX_CODE, + message=invalid_call.message, + location=location, + help=invalid_call.help, + ), + ) + reported += 1 + return reported + + +def authored_file_offset( + *, test_file: DiscoveredSqlTestFile, test_block: DiscoveredSqlTestBlock, body_offset: int +) -> int | None: + """Map an offset in a block's cleaned SQL back to the file, or None if it cannot be exact.""" + + span: tuple[int, int] | None = test_block.sql_body_span + if span is None: + return None + lines: tuple[_CleanedLine, ...] | None = _cleaned_lines( + authored=test_block.sql_body, span=span, contents=test_file.contents + ) + if lines is None: + return None + body: str = test_block.sql_body + line_index: int = body.count(_LINE_BREAK, 0, body_offset) + column: int = body_offset - (body.rfind(_LINE_BREAK, 0, body_offset) + 1) + cleaned: _CleanedLine = lines[line_index] + return cleaned.authored_start + _authored_index( + text=cleaned.authored_text, expanded_column=cleaned.removed_columns + column + ) + + +def _cleaned_lines( + *, authored: str, span: tuple[int, int], contents: str +) -> tuple[_CleanedLine, ...] | None: + text: str = contents[span[0] : span[1]] + raw_lines: list[str] = text.split(_LINE_BREAK) + expanded: list[str] = [line.expandtabs(_TAB_SIZE) for line in raw_lines] + indents: list[int] = [len(line) - len(line.lstrip()) for line in expanded[1:] if line.strip()] + margin: int = min(indents, default=sys.maxsize) + starts: list[int] = [] + cursor: int = span[0] + for line in raw_lines: + starts.append(cursor) + cursor += len(line) + 1 + removed: list[int] = [ + len(expanded[0]) - len(expanded[0].lstrip()), + *([margin if margin < sys.maxsize else 0] * (len(expanded) - 1)), + ] + kept: list[int] = list(range(len(expanded))) + while kept and not expanded[kept[-1]][removed[kept[-1]] :]: + kept.pop() + while kept and not expanded[kept[0]][removed[kept[0]] :]: + kept.pop(0) + cleaned: str = _LINE_BREAK.join(expanded[index][removed[index] :] for index in kept) + if cleaned != authored: + return None + return tuple( + _CleanedLine( + authored_start=starts[index], + authored_text=raw_lines[index], + removed_columns=removed[index], + ) + for index in kept + ) + + +def _authored_index(*, text: str, expanded_column: int) -> int: + position: int = 0 + column: int = 0 + index: int + character: str + for index, character in enumerate(text): + width: int = _TAB_SIZE - column % _TAB_SIZE if character == _TAB else 1 + if expanded_column < position + width: + return index + position += width + column = 0 if character == _CARRIAGE_RETURN else column + width + return len(text) diff --git a/src/sqlbuild/compiler/compile/_helpers/sql_tests/native.py b/src/sqlbuild/compiler/compile/_helpers/sql_tests/native.py index 8792025f14..8c2a4aa13f 100644 --- a/src/sqlbuild/compiler/compile/_helpers/sql_tests/native.py +++ b/src/sqlbuild/compiler/compile/_helpers/sql_tests/native.py @@ -1,20 +1,22 @@ -"""Native batch boundary for expanded SQL-test extraction.""" +"""Native boundary for SQL-test extraction before and after expansion, under adapter rules.""" from __future__ import annotations -from collections.abc import Iterator from pathlib import Path from typing import Protocol, cast import orjson import sqlbuild._native as _native -from sqlbuild.compiler.compile._helpers.sql_tests.core import extract_sql_test_ctes from sqlbuild.compiler.compile.constants import ( SQL_TEST_FACT_CACHE_ALGORITHM, SQL_TEST_FACT_CACHE_NAMESPACE, ) -from sqlbuild.compiler.compile.exceptions import CompileInputError, NativeSqlTestResponseError +from sqlbuild.compiler.compile.exceptions import ( + CompileInputError, + NativeSqlTestResponseError, + SqlTestExtractionError, +) from sqlbuild.compiler.compile.models import ( CompileDirectLogicSqlTestCtes, CompileModelSqlTestCtes, @@ -31,8 +33,17 @@ def extract_sql_tests_json(self, request_json: str) -> str: ... _DIRECT_KIND: str = "direct" +_SYNTAX_FIELDS: dict[str, str] = { + "backslash_escape_quotes": "backslashEscapeQuotes", + "escape_string_prefix": "escapeStringPrefix", + "raw_string_prefix": "rawStringPrefix", + "triple_quoted_strings": "tripleQuotedStrings", + "nested_block_comments": "nestedBlockComments", + "line_comment_prefixes": "lineCommentPrefixes", +} _MODEL_KIND: str = "model" _PAIR_LENGTH: int = 2 +_AUTHORED_CTE_LENGTH: int = 3 def extract_expanded_sql_tests_cached( @@ -78,9 +89,13 @@ def extract_expanded_sql_tests_cached( for file_label in missing_files: missing_indexes.extend(indexes_by_file[file_label]) if missing_indexes: - extracted: tuple[CompileSqlTestCtes, ...] = extract_expanded_sql_tests( - tests=tuple(tests[index] for index in missing_indexes), syntax=syntax - ) + try: + extracted: tuple[CompileSqlTestCtes, ...] = extract_expanded_sql_tests( + tests=tuple(tests[index] for index in missing_indexes), syntax=syntax + ) + except SqlTestExtractionError as error: + error.test_index = missing_indexes[error.test_index] + raise for index, test_ctes in zip(missing_indexes, extracted, strict=True): results[index] = test_ctes for file_label in missing_files: @@ -118,39 +133,94 @@ def extract_expanded_sql_tests( tests: tuple[tuple[str, str, SqlTestMode], ...], syntax: SqlLexicalSyntax, ) -> tuple[CompileSqlTestCtes, ...]: - """Extract and classify expanded tests, natively unless the dialect reads a test differently.""" + """Extract and classify expanded tests natively under the adapter's lexical rules.""" - dialect_indexes: frozenset[int] = frozenset( - index - for index, (sql, _file_label, _mode) in enumerate(tests) - if syntax.reads_differently_from_generic(sql) - ) - if not dialect_indexes: - return _extract_expanded_sql_tests_natively(tests) - native_results: Iterator[CompileSqlTestCtes] = iter( - _extract_expanded_sql_tests_natively( - tuple(test for index, test in enumerate(tests) if index not in dialect_indexes) - ) + if not tests: + return () + expanded: tuple[tuple[str, str, SqlTestMode, bool], ...] = tuple( + (sql, file_label, mode, False) for sql, file_label, mode in tests ) - return tuple( - extract_sql_test_ctes(sql=sql, file_label=file_label, syntax=syntax, mode=mode) - if index in dialect_indexes - else next(native_results) - for index, (sql, file_label, mode) in enumerate(tests) + extracted: tuple[tuple[CompileSqlTestCtes, bool], ...] = _extract_natively( + tests=expanded, syntax=syntax ) + return tuple(test_ctes for test_ctes, _invalid_calls in extracted) -def _extract_expanded_sql_tests_natively( - tests: tuple[tuple[str, str, SqlTestMode], ...], -) -> tuple[CompileSqlTestCtes, ...]: +def extract_unexpanded_sql_test( + *, sql: str, file_label: str, mode: SqlTestMode, syntax: SqlLexicalSyntax +) -> tuple[CompileSqlTestCtes, bool]: + """Extract a direct-logic test before expansion; also report whether it has P012 calls.""" + + return _extract_natively(tests=((sql, file_label, mode, True),), syntax=syntax)[0] + + +def authored_sql_test_ctes( + *, sql: str, file_label: str, syntax: SqlLexicalSyntax +) -> tuple[tuple[str, int, str], ...]: + """Read an authored block's CTEs with body offsets; only a CTE-name error is raised.""" + + return authored_sql_test_cte_batch(tests=((sql, file_label),), syntax=syntax)[0] + + +def authored_sql_test_cte_batch( + *, tests: tuple[tuple[str, str], ...], syntax: SqlLexicalSyntax +) -> tuple[tuple[tuple[str, int, str], ...], ...]: + """Read many authored blocks at once; a CTE-name error names its block by `test_index`.""" + if not tests: return () + results: list[object] = _native_batch( + tests=[ + {"sql": sql, "fileLabel": file_label, "mode": "model", "authored": True} + for sql, file_label in tests + ], + syntax=syntax, + ) + return tuple(_authored_ctes(item) for item in results) + + +def _authored_ctes(item: object) -> tuple[tuple[str, int, str], ...]: + ctes: object = cast(dict[str, object], item).get("ctes") if isinstance(item, dict) else None + if not isinstance(ctes, list): + raise CompileInputError("native SQL test extraction returned an invalid CTE response") + return tuple(_authored_cte(value) for value in ctes) + + +def _authored_cte(value: object) -> tuple[str, int, str]: + if not ( + isinstance(value, list) + and len(value) == _AUTHORED_CTE_LENGTH + and isinstance(value[0], str) + and isinstance(value[1], int) + and isinstance(value[2], str) + ): + raise CompileInputError("native SQL test extraction returned an invalid CTE") + return value[0], value[1], value[2] + + +def _extract_natively( + *, tests: tuple[tuple[str, str, SqlTestMode, bool], ...], syntax: SqlLexicalSyntax +) -> tuple[tuple[CompileSqlTestCtes, bool], ...]: + results: list[object] = _native_batch( + tests=[ + {"sql": sql, "fileLabel": file_label, "mode": mode.value, "raw": raw} + for sql, file_label, mode, raw in tests + ], + syntax=syntax, + ) + return tuple((_test_ctes_from_native(item), _invalid_calls(item)) for item in results) + + +def _invalid_calls(item: object) -> bool: + return isinstance(item, dict) and cast(dict[str, object], item).get("invalidCalls") is True + + +def _native_batch(*, tests: list[dict[str, object]], syntax: SqlLexicalSyntax) -> list[object]: + native_syntax: dict[str, object] = syntax.native_mapping request_json: str = orjson.dumps( { - "tests": [ - {"sql": sql, "fileLabel": file_label, "mode": mode.value} - for sql, file_label, mode in tests - ] + "syntax": {_SYNTAX_FIELDS[key]: value for key, value in native_syntax.items()}, + "tests": tests, } ).decode() try: @@ -159,9 +229,27 @@ def _extract_expanded_sql_tests_natively( ) except ValueError as error: raise CompileInputError(str(error)) from None - if not isinstance(response, list) or len(response) != len(tests): + if isinstance(response, dict) and isinstance(response.get("error"), dict): + raise _extraction_error(cast(dict[str, object], response["error"])) + results: object = response.get("tests") if isinstance(response, dict) else None + if not isinstance(results, list) or len(results) != len(tests): raise CompileInputError("native SQL test extraction returned an invalid batch response") - return tuple(_test_ctes_from_native(item) for item in response) + return cast(list[object], results) + + +def _extraction_error(error: dict[str, object]) -> SqlTestExtractionError: + message: object = error.get("message") + help_text: object = error.get("help") + index: object = error.get("index") + token: object = error.get("token") + token_offset: object = error.get("tokenOffset") + return SqlTestExtractionError( + message if isinstance(message, str) else "native SQL test extraction failed", + help=help_text if isinstance(help_text, str) else None, + test_index=index if isinstance(index, int) else 0, + token=token if isinstance(token, str) else None, + token_offset=token_offset if isinstance(token_offset, int) else None, + ) def _test_ctes_from_native(value: object) -> CompileSqlTestCtes: diff --git a/src/sqlbuild/compiler/compile/constants.py b/src/sqlbuild/compiler/compile/constants.py index 9be4a7b2f3..75ac536a3f 100644 --- a/src/sqlbuild/compiler/compile/constants.py +++ b/src/sqlbuild/compiler/compile/constants.py @@ -187,7 +187,7 @@ RENDER_REUSE_DECLARATIONS_PREFIX: str = "declarations:" RENDER_REUSE_ANALYSIS_PREFIX: str = "analysis:" SQL_TEST_FACT_CACHE_NAMESPACE: str = "sql-tests" -SQL_TEST_FACT_CACHE_ALGORITHM: str = "expanded-sql-test-ctes-v2" +SQL_TEST_FACT_CACHE_ALGORITHM: str = "expanded-sql-test-ctes-v3" SQL_TEST_EXPECTED_MODELS_FACT_ALGORITHM: str = "sql-test-expected-models-v2" COMPACT_ANALYSIS_RESPONSE_LENGTH: int = 2 COMPACT_RELATION_STUB_PREFIX: str = "__sqlbuild_project_input_" diff --git a/src/sqlbuild/compiler/compile/exceptions.py b/src/sqlbuild/compiler/compile/exceptions.py index 7416ff7e68..919c705d8e 100644 --- a/src/sqlbuild/compiler/compile/exceptions.py +++ b/src/sqlbuild/compiler/compile/exceptions.py @@ -37,6 +37,24 @@ class NativeSqlTestResponseError(CompileInputError): """Raised when native SQL-test extraction returns malformed data.""" +class SqlTestExtractionError(CompileInputError): + """Raised when a SQL test cannot be split into CTEs; names the test and offending text.""" + + def __init__( + self, + message: str, + *, + help: str | None = None, + test_index: int = 0, + token: str | None = None, + token_offset: int | None = None, + ) -> None: + super().__init__(message, help=help) + self.test_index = test_index + self.token = token + self.token_offset = token_offset + + class CompactAnalysisInputError(CompileInputError): """Raised when compact SQL-analysis batch inputs are inconsistent.""" diff --git a/src/sqlbuild/compiler/discovery/_helpers/native/sql_test_files.py b/src/sqlbuild/compiler/discovery/_helpers/native/sql_test_files.py index b161912ba6..9ea9fbe8be 100644 --- a/src/sqlbuild/compiler/discovery/_helpers/native/sql_test_files.py +++ b/src/sqlbuild/compiler/discovery/_helpers/native/sql_test_files.py @@ -97,7 +97,7 @@ def parse_native_sql_test_contents( _tag, _contents, native_blocks, block_failure = payload return _test_blocks( file_path=file_path, - native_blocks=cast(list[tuple[dict[str, object], str]], native_blocks), + native_blocks=cast(list[tuple[dict[str, object], str, tuple[int, int]]], native_blocks), block_failure=cast(tuple[object, ...] | None, block_failure), ) @@ -148,7 +148,7 @@ def _test_file( contents=str(native_contents), blocks=_test_blocks( file_path=file_path, - native_blocks=cast(list[tuple[dict[str, object], str]], native_blocks), + native_blocks=cast(list[tuple[dict[str, object], str, tuple[int, int]]], native_blocks), block_failure=cast(tuple[object, ...] | None, block_failure), ), ownership_root=Path(SQL_TESTS_OWNERSHIP_ROOT), @@ -158,7 +158,7 @@ def _test_file( def _test_blocks( *, file_path: Path, - native_blocks: list[tuple[dict[str, object], str]], + native_blocks: list[tuple[dict[str, object], str, tuple[int, int]]], block_failure: tuple[object, ...] | None, ) -> tuple[DiscoveredSqlTestBlock, ...]: blocks: tuple[DiscoveredSqlTestBlock, ...] = tuple( @@ -167,8 +167,9 @@ def _test_blocks( sql_body=sql_body, file_path=file_path, test_index=test_index, + sql_body_span=sql_body_span, ) - for test_index, (values, sql_body) in enumerate(native_blocks, start=1) + for test_index, (values, sql_body, sql_body_span) in enumerate(native_blocks, start=1) ) if block_failure is not None: raise native_failure(block_failure) diff --git a/src/sqlbuild/compiler/discovery/_helpers/sql/tests.py b/src/sqlbuild/compiler/discovery/_helpers/sql/tests.py index c1a66d0748..2cd0d975df 100644 --- a/src/sqlbuild/compiler/discovery/_helpers/sql/tests.py +++ b/src/sqlbuild/compiler/discovery/_helpers/sql/tests.py @@ -180,7 +180,12 @@ def _infer_sql_test_mode(*, sql_body: str, file_path: Path) -> SqlTestMode: def build_sql_test_block( - *, header_values: dict[str, object], sql_body: str, file_path: Path, test_index: int + *, + header_values: dict[str, object], + sql_body: str, + file_path: Path, + test_index: int, + sql_body_span: tuple[int, int] | None = None, ) -> DiscoveredSqlTestBlock: """Validate a header-parsed TEST block with only supported keys and build its record.""" @@ -228,6 +233,7 @@ def build_sql_test_block( cases=cases, cursor_start=cursor_start, cursor_end=cursor_end, + sql_body_span=sql_body_span, ) diff --git a/src/sqlbuild/compiler/discovery/models.py b/src/sqlbuild/compiler/discovery/models.py index 7238f531a0..ad2e2ad0c0 100644 --- a/src/sqlbuild/compiler/discovery/models.py +++ b/src/sqlbuild/compiler/discovery/models.py @@ -362,6 +362,7 @@ class DiscoveredSqlTestBlock: cases: tuple[DiscoveredSqlTestCase, ...] = field(default_factory=tuple) cursor_start: str | None = None cursor_end: str | None = None + sql_body_span: tuple[int, int] | None = None @dataclass(frozen=True) diff --git a/src/sqlbuild/compiler/sql_analysis/_helpers/set_operations.py b/src/sqlbuild/compiler/sql_analysis/_helpers/set_operations.py deleted file mode 100644 index 9cb2201df3..0000000000 --- a/src/sqlbuild/compiler/sql_analysis/_helpers/set_operations.py +++ /dev/null @@ -1,88 +0,0 @@ -"""Top-level SQL set-operation scanning over the shared code-position iterator.""" - -from __future__ import annotations - -from sqlbuild.compiler.sql_analysis._helpers.scanning import ( - is_identifier_character_impl, - iter_code_positions_impl, - skip_ignorable_impl, - strip_ignorable_impl, -) -from sqlbuild.compiler.sql_analysis.models import SqlLexicalSyntax - -_SET_OPERATOR_KEYWORDS: tuple[str, ...] = ("UNION", "INTERSECT") -_EXCEPT_KEYWORD: str = "EXCEPT" -_ALL_KEYWORD: str = "ALL" -_DISTINCT_KEYWORD: str = "DISTINCT" -_STAR_CHARACTER: str = "*" - - -def split_set_operation_branches_impl( - *, sql: str, context: str, syntax: SqlLexicalSyntax | None = None -) -> tuple[str, ...]: - """Split SQL on top-level set operators; branches exclude surrounding comments.""" - - branches: list[str] = [] - branch_start: int = 0 - resume: int = 0 - previous_code: str | None = None - previous_depth: int = 0 - index: int - depth: int - for index, depth in iter_code_positions_impl(sql=sql, context=context, syntax=syntax): - if depth != previous_depth: - previous_code = None - previous_depth = depth - operator_end: int | None = ( - None - if index < resume or depth != 0 - else _set_operator_end(sql=sql, start=index, previous_code=previous_code) - ) - if not sql[index].isspace(): - previous_code = sql[index] - if operator_end is None: - continue - branch_sql: str = strip_ignorable_impl( - sql=sql[branch_start:index], context=context, syntax=syntax - ) - if branch_sql: - branches.append(branch_sql) - resume = skip_ignorable_impl(sql=sql, start=operator_end, context=context, syntax=syntax) - quantifier_end: int | None = _keyword_end( - sql=sql, start=resume, keyword=_ALL_KEYWORD - ) or _keyword_end(sql=sql, start=resume, keyword=_DISTINCT_KEYWORD) - if quantifier_end is not None: - resume = skip_ignorable_impl( - sql=sql, start=quantifier_end, context=context, syntax=syntax - ) - branch_start = resume - final_branch_sql: str = strip_ignorable_impl( - sql=sql[branch_start:], context=context, syntax=syntax - ) - if final_branch_sql: - branches.append(final_branch_sql) - return tuple(branches) - - -def _set_operator_end(*, sql: str, start: int, previous_code: str | None) -> int | None: - """Return a set operator's end; `* EXCEPT (...)` star modifiers are not operators.""" - - keyword: str - for keyword in _SET_OPERATOR_KEYWORDS: - keyword_end: int | None = _keyword_end(sql=sql, start=start, keyword=keyword) - if keyword_end is not None: - return keyword_end - if previous_code == _STAR_CHARACTER: - return None - return _keyword_end(sql=sql, start=start, keyword=_EXCEPT_KEYWORD) - - -def _keyword_end(*, sql: str, start: int, keyword: str) -> int | None: - keyword_end: int = start + len(keyword) - if sql[start:keyword_end].upper() != keyword: - return None - if keyword_end < len(sql) and is_identifier_character_impl(sql[keyword_end]): - return None - if start > 0 and is_identifier_character_impl(sql[start - 1]): - return None - return keyword_end diff --git a/src/sqlbuild/compiler/sql_analysis/main/_split_set_operation_branches.py b/src/sqlbuild/compiler/sql_analysis/main/_split_set_operation_branches.py deleted file mode 100644 index 7fc70d7421..0000000000 --- a/src/sqlbuild/compiler/sql_analysis/main/_split_set_operation_branches.py +++ /dev/null @@ -1,14 +0,0 @@ -"""Top-level SQL set-operation branch splitting entrypoint.""" - -from sqlbuild.compiler.sql_analysis._helpers.set_operations import ( - split_set_operation_branches_impl, -) -from sqlbuild.compiler.sql_analysis.models import SqlLexicalSyntax - - -def split_set_operation_branches( - *, sql: str, context: str = "SQL", syntax: SqlLexicalSyntax | None = None -) -> tuple[str, ...]: - """Split SQL on top-level `UNION`, `INTERSECT` and `EXCEPT`, ignoring quotes and comments.""" - - return split_set_operation_branches_impl(sql=sql, context=context, syntax=syntax) diff --git a/tests/e2e/src/sqlbuild/cli/commands/main/test/_test_types.py b/tests/e2e/src/sqlbuild/cli/commands/main/test/_test_types.py index f8f9f23c33..403c5373fb 100644 --- a/tests/e2e/src/sqlbuild/cli/commands/main/test/_test_types.py +++ b/tests/e2e/src/sqlbuild/cli/commands/main/test/_test_types.py @@ -359,3 +359,14 @@ class HelperRedefinitionE2ETestCase: engine: str expected_output_fragments: tuple[str, ...] expected_absent_output_fragments: tuple[str, ...] + + +@dataclass(frozen=True) +class DirectLogicExtractionE2ETestCase: + """One direct-logic test project variant run through `sqb test` on one compiler engine.""" + + description: str + engine: str + overrides: dict[str, str] + expected_exit_code: int + expected_output_fragments: tuple[str, ...] diff --git a/tests/e2e/src/sqlbuild/cli/commands/main/test/test_direct_logic_extraction.py b/tests/e2e/src/sqlbuild/cli/commands/main/test/test_direct_logic_extraction.py new file mode 100644 index 0000000000..5c2d4f0ccf --- /dev/null +++ b/tests/e2e/src/sqlbuild/cli/commands/main/test/test_direct_logic_extraction.py @@ -0,0 +1,448 @@ +"""E2E: macro, UDF and table-function tests are split into CTEs under DuckDB's lexical rules. + +Every compiler engine extracts these tests natively before and after expansion, so comments, DuckDB +nested comments and escape strings hide calls the same way, and the strict authoring rules (plain CTE +names, no logic calls outside the actual CTE, P012 for malformed reference calls) report the same +located errors. +""" + +from __future__ import annotations + +from pathlib import Path +from subprocess import CompletedProcess + +import pytest + +from tests.e2e.src.sqlbuild.cli.commands.main.test._test_types import ( + DirectLogicExtractionE2ETestCase, +) +from tests.e2e.src.sqlbuild.cli.commands.shared.helpers import prepare_inline_project, run_sqb + +_ENGINE_ENV_VAR: str = "SQLBUILD_COMPILER_ENGINE" +_MACRO_TEST: str = "tests/unit/test_tidy_label.sql" +_UDF_TEST: str = "tests/unit/test_order_label.sql" +_MODEL_TEST: str = "tests/unit/test_order_summary.sql" +_PROJECT_FILES: dict[str, str] = { + "sqlbuild_project.toml": ( + 'name = "direct_logic"\nadapter = "duckdb"\n\n[connection]\ndatabase = "direct_logic.duckdb"\n' + ), + "models/stg_orders.sql": ( + "MODEL (description 'Staged orders.', materialized table);\n\n" + "SELECT 1 AS order_id, 'Placed ' AS status\n" + ), + "tests/unit/_macros/labels.py": ( + "def tidy_label(expression: str) -> str:\n" + ' """Trim and lower-case a label."""\n' + ' return f"LOWER(TRIM({expression}))"\n' + ), + "functions/sql/order_label.sql": ( + 'FUNCTION (\n description "Label an order status.",\n' + " arguments (raw_status VARCHAR),\n returns VARCHAR,\n);\n\nUPPER(raw_status)\n" + ), + "functions/sql/order_rows.sql": ( + 'FUNCTION (\n description "Orders above an id.",\n' + " arguments (min_id INTEGER),\n returns table (\n order_id INTEGER\n ),\n);\n\n" + "SELECT order_id FROM (VALUES (1), (2)) AS t(order_id) WHERE order_id > min_id\n" + ), + _MACRO_TEST: ( + "TEST (mode macro);\n\nWITH\n" + "input_values AS (\n" + " SELECT ' Placed ' AS status -- @tidy_label(\"status\")\n" + "),\n" + '__macro_actual__ AS (SELECT @tidy_label("status") AS status FROM input_values),\n' + "__macro_expected__ AS (SELECT 'placed' AS status)\n" + ), + _UDF_TEST: ( + "TEST (mode udf);\n\nWITH\n" + "input_values AS (\n" + " SELECT 'placed' AS status, E'it\\'s), (' AS note -- __udf(\"order_label\")(status)\n" + "),\n" + '__udf_actual__ AS (SELECT __udf("order_label")(status) AS label FROM input_values),\n' + "__udf_expected__ AS (\n" + " SELECT 'PLACED' AS label /* /* __udf(\"order_label\")('x') */ */\n" + ")\n" + ), + "models/order_summary.sql": ( + "MODEL (description 'Order summary.', materialized table);\n\n" + 'SELECT order_id, TRIM(status) AS status, order_id * 100 AS amount_cents FROM __ref("stg_orders")\n' + ), + _MODEL_TEST: ( + "TEST ();\n\nWITH\n" + "__ref__stg_orders AS (SELECT 1 AS order_id, 'placed' AS status),\n" + "__expected__order_summary AS (\n" + " SELECT 1 order_id, 'placed' \"status\", CAST(100 AS INTEGER) amount_cents\n" + ")\n" + ), + "tests/unit/test_order_rows.sql": ( + "TEST (mode table_fn);\n\nWITH\n" + '__table_fn_actual__ AS (SELECT order_id FROM __table_fn("order_rows")(1)),\n' + "__table_fn_expected__ AS (SELECT 2 AS order_id)\n" + ), +} +_QUOTED_NAME: dict[str, str] = { + _UDF_TEST: ( + "TEST (mode udf);\n\nWITH\n" + "\"input values\" AS (SELECT 'placed' AS status),\n" + '__udf_actual__ AS (SELECT __udf("order_label")(status) AS label FROM "input values"),\n' + "__udf_expected__ AS (SELECT 'PLACED' AS label)\n" + ) +} +_HELPER_CALLS_UDF: dict[str, str] = { + _MACRO_TEST: ( + "TEST (mode macro);\n\nWITH\n" + "input_values AS (SELECT __udf(\"order_label\")(' Placed ') AS status),\n" + '__macro_actual__ AS (SELECT @tidy_label("status") AS status FROM input_values),\n' + "__macro_expected__ AS (SELECT 'placed' AS status)\n" + ) +} +_MALFORMED_SOURCE: dict[str, str] = { + _UDF_TEST: ( + "TEST (mode udf);\n\nWITH\n" + 'input_values AS (SELECT status FROM __source("raw", "orders")),\n' + '__udf_actual__ AS (SELECT __udf("order_label")(status) AS label FROM input_values),\n' + "__udf_expected__ AS (SELECT 'PLACED' AS label)\n" + ) +} +_LOCATED_MODEL_NAME: dict[str, str] = { + _MODEL_TEST: ( + "TEST ();\n" + "-- the café helper holds the expected rows\n" + " WITH\n" + " __ref__stg_orders AS (SELECT 1 AS order_id, 'placed' AS status),\n" + " \tcafé AS (SELECT 1 AS order_id),\n" + " __expected__order_summary AS (SELECT order_id FROM café)\n" + ) +} +_LOCATED_CASE_NAME: dict[str, str] = { + _MACRO_TEST: ( + "TEST (\n mode macro,\n parameters (qty integer),\n" + " cases (one (qty 1)),\n);\n" + "-- a$b is named here before its CTE\n" + "WITH\n" + "input_values AS (SELECT ' Placed ' AS status, @param(\"qty\") AS qty),\n" + " a$b AS (SELECT 1 AS x),\n" + '__macro_actual__ AS (SELECT @tidy_label("status") AS status FROM input_values),\n' + "__macro_expected__ AS (SELECT 'placed' AS status)\n" + ) +} +_REPEATED_MALFORMED_SOURCE: dict[str, str] = { + _UDF_TEST: ( + "TEST (mode udf);\n\nWITH\n" + 'input_values AS (SELECT status FROM __source("raw", "orders")),\n' + "checked AS (\n" + ' SELECT status FROM input_values WHERE EXISTS (SELECT 1 FROM __source("raw", "orders"))\n' + "),\n" + '__udf_actual__ AS (SELECT __udf("order_label")(status) AS label FROM checked),\n' + "__udf_expected__ AS (SELECT 'PLACED' AS label)\n" + ) +} +_OPERATOR_MODEL: str = ( + "MODEL (description 'Order summary.', materialized table);\n\n" + "SELECT order_id, TRIM(status) AS status, order_id * 100 AS amount_cents,\n" + " TRIM(status) IS DISTINCT FROM 'shipped' AS is_open,\n" + " TRIM(status) IS NOT DISTINCT FROM 'placed' AS is_placed\n" + 'FROM __ref("stg_orders")\n' +) +_ALIASED_OPERATORS: dict[str, str] = { + "models/order_summary.sql": _OPERATOR_MODEL, + _MODEL_TEST: ( + "TEST ();\n\nWITH\n" + "__ref__stg_orders AS (SELECT 1 AS order_id, 'placed' AS status),\n" + "__expected__order_summary AS (\n" + " SELECT 1 order_id, 'placed' \"status\", CAST(100 AS INTEGER) amount_cents,\n" + " 'placed' IS DISTINCT FROM 'shipped' AS is_open,\n" + " 'placed' IS NOT DISTINCT FROM 'placed' AS is_placed\n" + ")\n" + ), + _MACRO_TEST: ( + "TEST (mode macro);\n\nWITH\n" + "input_values AS (SELECT ' Placed ' AS status),\n" + "__macro_actual__ AS (\n" + ' SELECT @tidy_label("status") AS status, [1, 2] AS item_ids FROM input_values\n' + "),\n" + "__macro_expected__ AS (SELECT 'placed' AS status, [1, 2] AS item_ids)\n" + ), +} +_UNALIASED_DISTINCT_FROM: dict[str, str] = { + "models/order_summary.sql": _OPERATOR_MODEL, + _MODEL_TEST: ( + "TEST ();\n\nWITH\n" + "__ref__stg_orders AS (SELECT 1 AS order_id, 'placed' AS status),\n" + "__expected__order_summary AS (\n" + " SELECT 1 order_id, 'placed' IS DISTINCT FROM 'shipped'\n" + ")\n" + ), +} +_TESTS: tuple[str, ...] = ( + "test_tidy_label", + "test_order_label", + "test_order_rows", + "test_order_summary", +) + + +_PASSING_FRAGMENTS: tuple[str, ...] = ("PASS=4 FAIL=0 TOTAL=4",) +_LOCATED_MODEL_NAME_FRAGMENTS: tuple[str, ...] = ( + "error[P001]: SQL test 'tests/unit/test_order_summary.sql' CTE name 'café' must be an " + "unquoted identifier", + "--> tests/unit/test_order_summary.sql:5:6", +) +_LOCATED_CASE_NAME_FRAGMENTS: tuple[str, ...] = ( + "error[P001]: SQL test 'tests/unit/test_tidy_label.sql' CTE name 'a$b' must be an " + "unquoted identifier", + "--> tests/unit/test_tidy_label.sql:9:3", +) +_REPEATED_MALFORMED_SOURCE_FRAGMENTS: tuple[str, ...] = ( + "tests/unit/test_order_label.sql:4:37", + "tests/unit/test_order_label.sql:6:63", +) +_QUOTED_NAME_FRAGMENTS: tuple[str, ...] = ( + "error[P001]: SQL test 'tests/unit/test_order_label.sql' CTE name " + '"input values" must be an unquoted identifier', + "--> tests/unit/test_order_label.sql:4:1", + "help: rename the CTE, for example input_values", +) +_HELPER_CALLS_UDF_FRAGMENTS: tuple[str, ...] = ( + "mode 'macro' helper CTE 'input_values' must not call udf; call reusable " + "logic only in __macro_actual__", +) +_MALFORMED_SOURCE_FRAGMENTS: tuple[str, ...] = ( + "error[P012]:", + "tests/unit/test_order_label.sql:4:37", + '__source("raw", "orders") is not a valid __source() call', +) + +_UNALIASED_DISTINCT_FROM_FRAGMENTS: tuple[str, ...] = ( + "SQL test 'tests/unit/test_order_summary.sql' must alias every non-trivial " + "__expected__order_summary projection", + "for example 'placed' IS DISTINCT FROM 'shipped' AS ", +) + + +@pytest.mark.parametrize( + "test_case", + [ + DirectLogicExtractionE2ETestCase( + description="macro, UDF and table-function tests pass on python", + engine="python", + overrides={}, + expected_exit_code=0, + expected_output_fragments=_PASSING_FRAGMENTS, + ), + DirectLogicExtractionE2ETestCase( + description="a quoted CTE name is a located error on python", + engine="python", + overrides=_QUOTED_NAME, + expected_exit_code=1, + expected_output_fragments=_QUOTED_NAME_FRAGMENTS, + ), + DirectLogicExtractionE2ETestCase( + description="a macro-test helper calling a UDF is rejected on python", + engine="python", + overrides=_HELPER_CALLS_UDF, + expected_exit_code=1, + expected_output_fragments=_HELPER_CALLS_UDF_FRAGMENTS, + ), + DirectLogicExtractionE2ETestCase( + description="a malformed helper reference is P012 at its call on python", + engine="python", + overrides=_MALFORMED_SOURCE, + expected_exit_code=1, + expected_output_fragments=_MALFORMED_SOURCE_FRAGMENTS, + ), + DirectLogicExtractionE2ETestCase( + description="macro, UDF and table-function tests pass on native", + engine="native", + overrides={}, + expected_exit_code=0, + expected_output_fragments=_PASSING_FRAGMENTS, + ), + DirectLogicExtractionE2ETestCase( + description="a quoted CTE name is a located error on native", + engine="native", + overrides=_QUOTED_NAME, + expected_exit_code=1, + expected_output_fragments=_QUOTED_NAME_FRAGMENTS, + ), + DirectLogicExtractionE2ETestCase( + description="a macro-test helper calling a UDF is rejected on native", + engine="native", + overrides=_HELPER_CALLS_UDF, + expected_exit_code=1, + expected_output_fragments=_HELPER_CALLS_UDF_FRAGMENTS, + ), + DirectLogicExtractionE2ETestCase( + description="a malformed helper reference is P012 at its call on native", + engine="native", + overrides=_MALFORMED_SOURCE, + expected_exit_code=1, + expected_output_fragments=_MALFORMED_SOURCE_FRAGMENTS, + ), + DirectLogicExtractionE2ETestCase( + description="macro, UDF and table-function tests pass on native-preview", + engine="native-preview", + overrides={}, + expected_exit_code=0, + expected_output_fragments=_PASSING_FRAGMENTS, + ), + DirectLogicExtractionE2ETestCase( + description="a quoted CTE name is a located error on native-preview", + engine="native-preview", + overrides=_QUOTED_NAME, + expected_exit_code=1, + expected_output_fragments=_QUOTED_NAME_FRAGMENTS, + ), + DirectLogicExtractionE2ETestCase( + description="a macro-test helper calling a UDF is rejected on native-preview", + engine="native-preview", + overrides=_HELPER_CALLS_UDF, + expected_exit_code=1, + expected_output_fragments=_HELPER_CALLS_UDF_FRAGMENTS, + ), + DirectLogicExtractionE2ETestCase( + description="a malformed helper reference is P012 at its call on native-preview", + engine="native-preview", + overrides=_MALFORMED_SOURCE, + expected_exit_code=1, + expected_output_fragments=_MALFORMED_SOURCE_FRAGMENTS, + ), + DirectLogicExtractionE2ETestCase( + description="a CTE name in an indented model test is located exactly on python", + engine="python", + overrides=_LOCATED_MODEL_NAME, + expected_exit_code=1, + expected_output_fragments=_LOCATED_MODEL_NAME_FRAGMENTS, + ), + DirectLogicExtractionE2ETestCase( + description="a CTE name in a test with cases is located exactly on python", + engine="python", + overrides=_LOCATED_CASE_NAME, + expected_exit_code=1, + expected_output_fragments=_LOCATED_CASE_NAME_FRAGMENTS, + ), + DirectLogicExtractionE2ETestCase( + description="each repeated malformed reference is P012 at its own call on python", + engine="python", + overrides=_REPEATED_MALFORMED_SOURCE, + expected_exit_code=1, + expected_output_fragments=_REPEATED_MALFORMED_SOURCE_FRAGMENTS, + ), + DirectLogicExtractionE2ETestCase( + description="a CTE name in an indented model test is located exactly on native", + engine="native", + overrides=_LOCATED_MODEL_NAME, + expected_exit_code=1, + expected_output_fragments=_LOCATED_MODEL_NAME_FRAGMENTS, + ), + DirectLogicExtractionE2ETestCase( + description="a CTE name in a test with cases is located exactly on native", + engine="native", + overrides=_LOCATED_CASE_NAME, + expected_exit_code=1, + expected_output_fragments=_LOCATED_CASE_NAME_FRAGMENTS, + ), + DirectLogicExtractionE2ETestCase( + description="each repeated malformed reference is P012 at its own call on native", + engine="native", + overrides=_REPEATED_MALFORMED_SOURCE, + expected_exit_code=1, + expected_output_fragments=_REPEATED_MALFORMED_SOURCE_FRAGMENTS, + ), + DirectLogicExtractionE2ETestCase( + description="a CTE name in an indented model test is located exactly on native-preview", + engine="native-preview", + overrides=_LOCATED_MODEL_NAME, + expected_exit_code=1, + expected_output_fragments=_LOCATED_MODEL_NAME_FRAGMENTS, + ), + DirectLogicExtractionE2ETestCase( + description="a CTE name in a test with cases is located exactly on native-preview", + engine="native-preview", + overrides=_LOCATED_CASE_NAME, + expected_exit_code=1, + expected_output_fragments=_LOCATED_CASE_NAME_FRAGMENTS, + ), + DirectLogicExtractionE2ETestCase( + description="each repeated malformed reference is P012 at its own call on native-preview", + engine="native-preview", + overrides=_REPEATED_MALFORMED_SOURCE, + expected_exit_code=1, + expected_output_fragments=_REPEATED_MALFORMED_SOURCE_FRAGMENTS, + ), + DirectLogicExtractionE2ETestCase( + description="aliased IS [NOT] DISTINCT FROM and list projections pass on python", + engine="python", + overrides=_ALIASED_OPERATORS, + expected_exit_code=0, + expected_output_fragments=_PASSING_FRAGMENTS, + ), + DirectLogicExtractionE2ETestCase( + description="an unaliased IS DISTINCT FROM projection is rejected on python", + engine="python", + overrides=_UNALIASED_DISTINCT_FROM, + expected_exit_code=1, + expected_output_fragments=_UNALIASED_DISTINCT_FROM_FRAGMENTS, + ), + DirectLogicExtractionE2ETestCase( + description="aliased IS [NOT] DISTINCT FROM and list projections pass on native", + engine="native", + overrides=_ALIASED_OPERATORS, + expected_exit_code=0, + expected_output_fragments=_PASSING_FRAGMENTS, + ), + DirectLogicExtractionE2ETestCase( + description="an unaliased IS DISTINCT FROM projection is rejected on native", + engine="native", + overrides=_UNALIASED_DISTINCT_FROM, + expected_exit_code=1, + expected_output_fragments=_UNALIASED_DISTINCT_FROM_FRAGMENTS, + ), + DirectLogicExtractionE2ETestCase( + description="aliased IS [NOT] DISTINCT FROM and list projections pass on native-preview", + engine="native-preview", + overrides=_ALIASED_OPERATORS, + expected_exit_code=0, + expected_output_fragments=_PASSING_FRAGMENTS, + ), + DirectLogicExtractionE2ETestCase( + description="an unaliased IS DISTINCT FROM projection is rejected on native-preview", + engine="native-preview", + overrides=_UNALIASED_DISTINCT_FROM, + expected_exit_code=1, + expected_output_fragments=_UNALIASED_DISTINCT_FROM_FRAGMENTS, + ), + ], + ids=lambda case: case.description, +) +def test_given_direct_logic_tests_when_testing_then_extraction_rules_hold_on_every_engine( + test_case: DirectLogicExtractionE2ETestCase, tmp_path: Path +) -> None: + project_dir: Path = prepare_inline_project( + tmp_path=tmp_path, + project_name="direct_logic", + repo_files={**_PROJECT_FILES, **test_case.overrides}, + ) + + _ = run_sqb( + command=("--no-color", "build", "--no-tests"), + project_dir=project_dir, + env={_ENGINE_ENV_VAR: test_case.engine}, + ) + tested: CompletedProcess[str] = run_sqb( + command=("--no-color", "test", "--select", *_TESTS), + project_dir=project_dir, + env={_ENGINE_ENV_VAR: test_case.engine}, + ) + + output: str = tested.stdout + tested.stderr + assert ( + tested.returncode, + tuple(fragment in output for fragment in test_case.expected_output_fragments), + ) == ( + test_case.expected_exit_code, + tuple(True for _ in test_case.expected_output_fragments), + ), output + + +if __name__ == "__main__": + pytest.main([__file__, "-vv"]) diff --git a/tests/integration/src/sqlbuild/compiler/attachments/_test_types.py b/tests/integration/src/sqlbuild/compiler/attachments/_test_types.py index cca7097b9f..7e47c866a5 100644 --- a/tests/integration/src/sqlbuild/compiler/attachments/_test_types.py +++ b/tests/integration/src/sqlbuild/compiler/attachments/_test_types.py @@ -120,3 +120,13 @@ class FunctionHeaderParityTestCase: inherit_default_namespace: bool expected_minimum_attached: int expected_minimum_python_errors: int + + +@dataclass(frozen=True) +class BodyCallParityTestCase: + """Seeded helper bodies whose call reading the native extractor shares with a Python scanner.""" + + description: str + seed: int + count: int + expected_minimum_calls: int diff --git a/tests/integration/src/sqlbuild/compiler/attachments/helpers.py b/tests/integration/src/sqlbuild/compiler/attachments/helpers.py index 37d89ce9db..a084961c28 100644 --- a/tests/integration/src/sqlbuild/compiler/attachments/helpers.py +++ b/tests/integration/src/sqlbuild/compiler/attachments/helpers.py @@ -3,6 +3,7 @@ from __future__ import annotations import random +import re from collections.abc import Callable from dataclasses import dataclass from functools import partial @@ -29,15 +30,15 @@ build_test_inputs, validate_test_ctes, ) +from sqlbuild.compiler.compile._helpers.refs.references import scan_sql_reference_calls from sqlbuild.compiler.compile._helpers.render import macros from sqlbuild.compiler.compile._helpers.render.cursor_intrinsics import reject_cursor_intrinsics +from sqlbuild.compiler.compile._helpers.render.macros import find_macro_call_names from sqlbuild.compiler.compile._helpers.render.parameters import expand_test_parameters from sqlbuild.compiler.compile._helpers.render.sql_vars import expand_authored_sql_result from sqlbuild.compiler.compile._helpers.scenarios.core import extract_sql_scenario_ctes -from sqlbuild.compiler.compile._helpers.sql_tests.core import ( - complete_omitted_ceremonial_select, - extract_sql_test_ctes, -) +from sqlbuild.compiler.compile._helpers.sql_tests.core import complete_omitted_ceremonial_select +from sqlbuild.compiler.compile._helpers.sql_tests.native import extract_unexpanded_sql_test from sqlbuild.compiler.compile.exceptions import CompileInputError from sqlbuild.compiler.compile.main._build_compile_inputs import build_compile_inputs from sqlbuild.compiler.compile.models import ( @@ -50,6 +51,7 @@ DeclarationResolutionContext, LoadedMacro, MacroContext, + SqlReferenceScan, ) from sqlbuild.compiler.compile.types import SqlTestMode from sqlbuild.compiler.discovery.main.discover import discover_project_inputs @@ -742,11 +744,11 @@ def generated_raw_direct_logic_test(*, rng: random.Random) -> tuple[str, SqlTest return f"WITH {body}" + rng.choice(("", "", " SELECT 1", ";")), mode -def python_raw_extraction_accepts(*, sql: str, mode: SqlTestMode) -> bool: - """Whether Python's raw direct-logic pass accepts `sql` before expansion.""" +def raw_extraction_accepts(*, sql: str, mode: SqlTestMode) -> bool: + """Whether the raw direct-logic pass accepts `sql` before expansion.""" try: - extract_sql_test_ctes( + _ = extract_unexpanded_sql_test( sql=sql, file_label="tests/unit/test_logic.sql", syntax=DuckDbAdapter().sql_lexical_syntax, @@ -1028,3 +1030,156 @@ def function_outcome( ) except CompileInputError as error: return f"error: {error}" + + +_MACRO_PIECES: tuple[str, ...] = ( + "a", + "@m(1)", + "@m (1)", + "@ab (1)", + "@ab cd(1)", + "@enum('Status', 'PAID')", + "@const(@m(1))", + "@var('x')", + '@param("p")', + "@@region", + "@m$x(1)", + '@"m"(1)', + "@\u00e9t\u00e9(1)", + "@m\u00a0(1)", + "@mm\u3000(1)", + "@mm\x1c(1)", + "'@m(1)'", + "-- @m(1)\n", + "/* @m(1) */", + "$$@m(1)$$", + "$t$@m(1)$t$", + "`@m(1)`", + '"@m(1)"', + "x@m(1)", + "@m(@n(1))", + "@_m(1)", + "@1m(1)", + "@\u216b(1)", +) +_REFERENCE_PIECES: tuple[str, ...] = ( + "a", + '__udf("order_label")(a)', + '__table_fn("order_rows")(1)', + '__ref("orders")', + '__source("events", "orders")', + '__source("events")', + '__dbt_ref("shop", "orders")', + '__dbt_ref("orders")', + "__dbt_ref(orders)", + "__udf('order_label')", + '__udf("order_label", 1)', + "__udf(order_label)", + '__udf("")', + '__udf( "order_label" )', + '__table_fn("order_rows")', + '__table_fn("order_rows")\u00a0(1)', + '__table_fn("order_rows") (1)', + '__UDF("order_label")', + '__udf ("order_label")', + 'x__udf("order_label")(a)', + "'__udf(\"order_label\")'", + '-- __udf("order_label")\n', + '# __udf("order_label")\n', + '// __udf("order_label")\n', + '/* /* __udf("order_label") */ */', + "'it''s'", + '$$__udf("order_label")$$', + '`__udf("order_label")`', +) +_LOGIC_REFERENCE_KINDS: frozenset[str] = frozenset({"udf", "table_fn"}) +BODY_CALL_SYNTAXES: dict[str, SqlLexicalSyntax] = { + "generic": SqlLexicalSyntax(), + "duckdb": SqlLexicalSyntax(escape_string_prefix=True, nested_block_comments=True), + "bigquery": SqlLexicalSyntax( + backslash_escape_quotes=frozenset({"'", '"', "`"}), + triple_quoted_strings=True, + line_comment_prefixes=frozenset({"--", "#"}), + ), + "snowflake": SqlLexicalSyntax( + backslash_escape_quotes=frozenset({"'"}), line_comment_prefixes=frozenset({"--", "//"}) + ), +} + + +def generated_macro_body(*, rng: random.Random) -> str: + """Return one helper body mixing macro calls and macro-like text.""" + + pieces: list[str] = [rng.choice(_MACRO_PIECES) for _ in range(rng.randint(1, 3))] + return "SELECT " + " + ".join(pieces) + " AS a" + + +def generated_reference_body(*, rng: random.Random) -> str: + """Return one helper body mixing valid, malformed and hidden reference calls.""" + + pieces: list[str] = [rng.choice(_REFERENCE_PIECES) for _ in range(rng.randint(1, 3))] + return "SELECT " + " + ".join(pieces) + "\n AS a" + + +def python_macro_outcome(*, body: str) -> str: + """Whether expansion's macro scanner finds a call in `body`, or that it raises.""" + + try: + return f"calls macros {bool(find_macro_call_names(body))}" + except CompileInputError: + return "unscannable" + + +def macro_scannable(body: str) -> bool: + """Whether expansion's macro scanner reads `body` without raising.""" + + return python_macro_outcome(body=body) != "unscannable" + + +def native_macro_outcome(*, body: str) -> str: + """Whether the raw extractor rejects a macro-test helper `body` for calling macros.""" + + try: + _ = extract_unexpanded_sql_test( + sql=( + f"WITH h AS ({body}), __macro_actual__ AS (SELECT @m(1) AS a), " + "__macro_expected__ AS (SELECT 1 AS a)" + ), + file_label="tests/unit/test_logic.sql", + mode=SqlTestMode.MACRO, + syntax=SqlLexicalSyntax(), + ) + except CompileInputError as error: + return f"calls macros {'must not call macros' in str(error)}" + return "calls macros False" + + +def python_reference_outcome(*, body: str, syntax: SqlLexicalSyntax) -> str: + """Python's first `__udf`/`__table_fn` kind in `body`, or whether it has P012 calls.""" + + try: + scan: SqlReferenceScan = scan_sql_reference_calls(sql=body, syntax=syntax) + except CompileInputError as error: + return f"error: {error}" + kinds: list[str] = [str(reference.ref_kind) for reference in scan.references] + kind: str | None = next(filter(_LOGIC_REFERENCE_KINDS.__contains__, kinds), None) + return (kind and f"calls {kind}") or f"malformed calls {bool(scan.invalid_calls)}" + + +def native_reference_outcome(*, body: str, syntax: SqlLexicalSyntax) -> str: + """The raw extractor's reading of a UDF-test helper `body`, as `python_reference_outcome`.""" + + try: + _, invalid_calls = extract_unexpanded_sql_test( + sql=( + f"WITH h AS ({body}), __udf_actual__ AS (SELECT 1 AS a), " + "__udf_expected__ AS (SELECT 1 AS a)" + ), + file_label="tests/unit/test_logic.sql", + mode=SqlTestMode.UDF, + syntax=syntax, + ) + except CompileInputError as error: + called: re.Match[str] | None = re.search(r"must not call (\w+);", str(error)) + return (called and f"calls {called.group(1)}") or f"error: {error}" + return f"malformed calls {invalid_calls}" diff --git a/tests/integration/src/sqlbuild/compiler/attachments/test_native_raw_direct_logic_tests.py b/tests/integration/src/sqlbuild/compiler/attachments/test_native_raw_direct_logic_tests.py index 1190e376e1..4d0a156133 100644 --- a/tests/integration/src/sqlbuild/compiler/attachments/test_native_raw_direct_logic_tests.py +++ b/tests/integration/src/sqlbuild/compiler/attachments/test_native_raw_direct_logic_tests.py @@ -1,7 +1,7 @@ -"""Unexpanded direct-logic SQL tests keep Python's extraction under the preview engine. +"""Unexpanded direct-logic SQL tests build identical inputs under every compiler engine. -The native extractor reads expanded tests and accepts shapes Python rejects before expansion, -such as quoted CTE names or calls in `__macro_expected__`; the raw pass must stay Python's. +Every engine extracts tests before expansion with the native extractor, so quoted CTE names, +calls in `__macro_expected__` and malformed reference calls are rejected the same way. """ from __future__ import annotations @@ -17,7 +17,7 @@ ) from tests.integration.src.sqlbuild.compiler.attachments.helpers import ( generated_raw_direct_logic_test, - python_raw_extraction_accepts, + raw_extraction_accepts, raw_test_compile_outcome, ) from tests.integration.src.sqlbuild.compiler.helpers import mismatches @@ -59,7 +59,7 @@ def test_given_unexpanded_direct_logic_tests_when_compiling_in_preview_then_pyth assert ( mismatches(inputs=[*tests], expected=[*python], actual=[*native]), - sum(python_raw_extraction_accepts(sql=sql, mode=mode) for sql, mode in tests) + sum(raw_extraction_accepts(sql=sql, mode=mode) for sql, mode in tests) >= test_case.expected_minimum_extracted, sum(item.startswith("error: ") for item in python) >= test_case.expected_minimum_python_errors, diff --git a/tests/integration/src/sqlbuild/compiler/attachments/test_native_sql_test_body_calls.py b/tests/integration/src/sqlbuild/compiler/attachments/test_native_sql_test_body_calls.py new file mode 100644 index 0000000000..73216840fc --- /dev/null +++ b/tests/integration/src/sqlbuild/compiler/attachments/test_native_sql_test_body_calls.py @@ -0,0 +1,99 @@ +"""The native SQL-test extractor reads helper calls as Python's macro and reference scanners do. + +Macro calls follow expansion's scanner, which decides what a helper would actually expand; a body +that scanner cannot read is left for expansion to reject. Reference calls follow the reference +scanner under each adapter's rules: only valid `__udf`/`__table_fn` calls count, the first in +text order names the kind, and malformed calls are reported as P012. +""" + +from __future__ import annotations + +import random + +import pytest + +from tests.integration.src.sqlbuild.compiler.attachments._test_types import ( + BodyCallParityTestCase, +) +from tests.integration.src.sqlbuild.compiler.attachments.helpers import ( + BODY_CALL_SYNTAXES, + generated_macro_body, + generated_reference_body, + macro_scannable, + native_macro_outcome, + native_reference_outcome, + python_macro_outcome, + python_reference_outcome, +) +from tests.integration.src.sqlbuild.compiler.helpers import mismatches + + +@pytest.mark.parametrize( + "test_case", + [ + BodyCallParityTestCase( + description="macro calls, declaration calls and macro-like text", + seed=20261008, + count=4000, + expected_minimum_calls=1000, + ), + ], + ids=lambda case: case.description, +) +def test_given_helper_bodies_when_detecting_macro_calls_then_expansion_scanner_matches( + test_case: BodyCallParityTestCase, +) -> None: + rng: random.Random = random.Random(test_case.seed) + bodies: list[str] = [generated_macro_body(rng=rng) for _ in range(test_case.count)] + python: list[object] = [python_macro_outcome(body=body) for body in bodies] + scannable: list[str] = list(filter(macro_scannable, bodies)) + + assert ( + mismatches( + inputs=[*scannable], + expected=[python_macro_outcome(body=body) for body in scannable], + actual=[native_macro_outcome(body=body) for body in scannable], + ), + python.count("calls macros True") >= test_case.expected_minimum_calls, + ) == ([], True), test_case.description + + +@pytest.mark.parametrize( + "test_case", + [ + BodyCallParityTestCase( + description="valid, malformed and hidden reference calls under four adapters", + seed=20261009, + count=4000, + expected_minimum_calls=1000, + ), + ], + ids=lambda case: case.description, +) +def test_given_helper_bodies_when_reading_reference_calls_then_reference_scanner_matches( + test_case: BodyCallParityTestCase, +) -> None: + rng: random.Random = random.Random(test_case.seed) + cases: list[tuple[str, str]] = [ + (rng.choice(tuple(BODY_CALL_SYNTAXES)), generated_reference_body(rng=rng)) + for _ in range(test_case.count) + ] + python: list[object] = [ + python_reference_outcome(body=body, syntax=BODY_CALL_SYNTAXES[name]) for name, body in cases + ] + + assert ( + mismatches( + inputs=[*cases], + expected=python, + actual=[ + native_reference_outcome(body=body, syntax=BODY_CALL_SYNTAXES[name]) + for name, body in cases + ], + ), + python.count("malformed calls True") >= test_case.expected_minimum_calls, + ) == ([], True), test_case.description + + +if __name__ == "__main__": + pytest.main([__file__, "-vv"]) diff --git a/tests/unit/src/sqlbuild/compiler/compile/_helpers/_test_types.py b/tests/unit/src/sqlbuild/compiler/compile/_helpers/_test_types.py index 22280c108a..70a6f872a3 100644 --- a/tests/unit/src/sqlbuild/compiler/compile/_helpers/_test_types.py +++ b/tests/unit/src/sqlbuild/compiler/compile/_helpers/_test_types.py @@ -872,6 +872,13 @@ class MicrobatchGrainOwnershipTestCase: expected_grain: str +@dataclass(frozen=True) +class ExpectedMessageTestCase: + description: str + value: str + expected_message: str + + @dataclass(frozen=True) class ExpectedBooleanTestCase: description: str @@ -914,17 +921,6 @@ class CompleteCeremonialSelectTestCase: expected_sql: str -@dataclass(frozen=True) -class ExpectedProjectionScanTestCase: - description: str - sql: str - expected_branches: tuple[str, ...] - expected_select_list_end: int - expected_commas: tuple[str, ...] - expected_alias: str | None - expected_contains_select_star: bool - - @dataclass(frozen=True) class SetOperationExpectedKind: """One SQL-test kind whose expected CTE accepts set operations.""" @@ -1150,14 +1146,6 @@ class RequiredDescriptionLocationCase: expected_location: tuple[str, int, int] -@dataclass(frozen=True) -class DialectSetOperationSplitTestCase: - description: str - sql: str - syntax: SqlLexicalSyntax | None - expected_branches: tuple[str, ...] - - @dataclass(frozen=True) class CursorModelWithoutInputsCase: """One model header and query checked by the cursor-input compile check (P011).""" @@ -1175,3 +1163,11 @@ class CollectedReferenceCallSyntaxTestCase: contents: str sql: str expected_diagnostics: tuple[tuple[str, str, Path, SourceLocation | None], ...] + + +@dataclass(frozen=True) +class AuthoredOffsetTestCase: + description: str + contents: str + marker: str + expected_line_column: tuple[int, int] diff --git a/tests/unit/src/sqlbuild/compiler/compile/_helpers/helpers.py b/tests/unit/src/sqlbuild/compiler/compile/_helpers/helpers.py index f5d4c219df..f051c38021 100644 --- a/tests/unit/src/sqlbuild/compiler/compile/_helpers/helpers.py +++ b/tests/unit/src/sqlbuild/compiler/compile/_helpers/helpers.py @@ -27,6 +27,7 @@ prepare_static_project_vars_batch, substitute_sql_vars, ) +from sqlbuild.compiler.compile._helpers.sql_tests.native import extract_unexpanded_sql_test from sqlbuild.compiler.compile.exceptions import CompileInputError from sqlbuild.compiler.compile.main._assemble_project import assemble_project from sqlbuild.compiler.compile.main._build_compile_inputs import build_compile_inputs @@ -51,6 +52,7 @@ CompileSourceInput, CompileSqlFunctionInput, CompileSqlReference, + CompileSqlTestCtes, DeclarationExpansionContext, DeclarationResolutionContext, DeclarationRuntimeProjection, @@ -58,7 +60,7 @@ LoadedMacro, MacroContext, ) -from sqlbuild.compiler.compile.types import CompiledResourceType, FunctionLanguage +from sqlbuild.compiler.compile.types import CompiledResourceType, FunctionLanguage, SqlTestMode from sqlbuild.compiler.discovery.main.discover import discover_project_inputs from sqlbuild.compiler.discovery.models import ( DiscoveredLoaderFunction, @@ -91,6 +93,7 @@ VisibilityRecord, ) from sqlbuild.compiler.scopes.types import DeclarationKind, ResourceKind, ScopeKind +from sqlbuild.compiler.sql_analysis.models import SqlLexicalSyntax from sqlbuild.providers import Provider from sqlbuild.spec.contracts.models import ( LocalConfig, @@ -903,3 +906,25 @@ def _python_substituted_sql(*, sql: str, effective_vars: dict[str, object]) -> s ) except CompileInputError: return None + + +def extract_test_ctes( + *, ctes: tuple[tuple[str, str], ...], file_label: str, mode: SqlTestMode +) -> CompileSqlTestCtes: + """Extract a SQL test made of `ctes` before expansion under generic SQL rules.""" + + return extract_test_sql( + sql="WITH " + ", ".join(f"{name} AS ({body})" for name, body in ctes), + file_label=file_label, + mode=mode, + ) + + +def extract_test_sql( + *, sql: str, file_label: str, mode: SqlTestMode = SqlTestMode.MODEL +) -> CompileSqlTestCtes: + """Extract one SQL test before expansion under generic SQL rules.""" + + return extract_unexpanded_sql_test( + sql=sql, file_label=file_label, mode=mode, syntax=SqlLexicalSyntax() + )[0] diff --git a/tests/unit/src/sqlbuild/compiler/compile/_helpers/test_direct_logic_sql_test_classification.py b/tests/unit/src/sqlbuild/compiler/compile/_helpers/test_direct_logic_sql_test_classification.py index ed93ad2b24..b99112888d 100644 --- a/tests/unit/src/sqlbuild/compiler/compile/_helpers/test_direct_logic_sql_test_classification.py +++ b/tests/unit/src/sqlbuild/compiler/compile/_helpers/test_direct_logic_sql_test_classification.py @@ -1,17 +1,19 @@ -"""Characterisation tests for macro, UDF, and table_fn SQL-test CTE classification errors.""" +"""Characterisation tests for macro, UDF, and table_fn SQL-test CTE classification errors. + +Duplicate actual or expected CTEs are duplicate CTE names, which the CTE scan rejects first. +""" from __future__ import annotations import pytest -from sqlbuild.compiler.compile._helpers.sql_tests.core import classify_sql_test_ctes from sqlbuild.compiler.compile.exceptions import CompileInputError -from sqlbuild.compiler.compile.models import CompileSqlTestCte from sqlbuild.compiler.compile.types import SqlTestMode from sqlbuild.compiler.sql_analysis.models import SqlLexicalSyntax from tests.unit.src.sqlbuild.compiler.compile._helpers._test_types import ( ClassifyDirectLogicSqlTestCtesErrorTestCase, ) +from tests.unit.src.sqlbuild.compiler.compile._helpers.helpers import extract_test_ctes _GENERIC_SQL_SYNTAX: SqlLexicalSyntax = SqlLexicalSyntax() @@ -25,9 +27,7 @@ description="macro duplicate actual", mode=SqlTestMode.MACRO, ctes=(("__macro_actual__", "SELECT 1"), ("__macro_actual__", "SELECT 2")), - expected_message=( - f"SQL test '{_FILE}' mode 'macro' must define exactly one __macro_actual__ CTE" - ), + expected_message=f"SQL test '{_FILE}' defines duplicate CTE '__macro_actual__'", ), ClassifyDirectLogicSqlTestCtesErrorTestCase( description="macro duplicate expected", @@ -36,9 +36,7 @@ ("__macro_expected__", "SELECT 1 AS value"), ("__macro_expected__", "SELECT 2 AS value"), ), - expected_message=( - f"SQL test '{_FILE}' mode 'macro' must define exactly one __macro_expected__ CTE" - ), + expected_message=f"SQL test '{_FILE}' defines duplicate CTE '__macro_expected__'", ), ClassifyDirectLogicSqlTestCtesErrorTestCase( description="macro model-test cte", @@ -131,9 +129,7 @@ description="udf duplicate actual", mode=SqlTestMode.UDF, ctes=(("__udf_actual__", "SELECT 1"), ("__udf_actual__", "SELECT 2")), - expected_message=( - f"SQL test '{_FILE}' mode 'udf' must define exactly one __udf_actual__ CTE" - ), + expected_message=f"SQL test '{_FILE}' defines duplicate CTE '__udf_actual__'", ), ClassifyDirectLogicSqlTestCtesErrorTestCase( description="udf duplicate expected", @@ -142,9 +138,7 @@ ("__udf_expected__", "SELECT 1 AS value"), ("__udf_expected__", "SELECT 2 AS value"), ), - expected_message=( - f"SQL test '{_FILE}' mode 'udf' must define exactly one __udf_expected__ CTE" - ), + expected_message=f"SQL test '{_FILE}' defines duplicate CTE '__udf_expected__'", ), ClassifyDirectLogicSqlTestCtesErrorTestCase( description="udf model-test cte", @@ -214,10 +208,7 @@ description="table_fn duplicate actual", mode=SqlTestMode.TABLE_FN, ctes=(("__table_fn_actual__", "SELECT 1"), ("__table_fn_actual__", "SELECT 2")), - expected_message=( - f"SQL test '{_FILE}' mode 'table_fn' must define exactly one " - "__table_fn_actual__ CTE" - ), + expected_message=f"SQL test '{_FILE}' defines duplicate CTE '__table_fn_actual__'", ), ClassifyDirectLogicSqlTestCtesErrorTestCase( description="table_fn duplicate expected", @@ -226,10 +217,7 @@ ("__table_fn_expected__", "SELECT 1 AS value"), ("__table_fn_expected__", "SELECT 2 AS value"), ), - expected_message=( - f"SQL test '{_FILE}' mode 'table_fn' must define exactly one " - "__table_fn_expected__ CTE" - ), + expected_message=f"SQL test '{_FILE}' defines duplicate CTE '__table_fn_expected__'", ), ClassifyDirectLogicSqlTestCtesErrorTestCase( description="table_fn model-test cte", @@ -279,13 +267,7 @@ def test_given_invalid_direct_logic_ctes_when_classifying_then_raises_mode_specific_message( test_case: ClassifyDirectLogicSqlTestCtesErrorTestCase, ) -> None: - ctes: tuple[CompileSqlTestCte, ...] = tuple( - CompileSqlTestCte(name=name, sql_body=body) for name, body in test_case.ctes - ) - with pytest.raises(CompileInputError) as error_info: - _ = classify_sql_test_ctes( - ctes=ctes, file_label=_FILE, mode=test_case.mode, syntax=_GENERIC_SQL_SYNTAX - ) + _ = extract_test_ctes(ctes=test_case.ctes, file_label=_FILE, mode=test_case.mode) assert str(error_info.value) == test_case.expected_message diff --git a/tests/unit/src/sqlbuild/compiler/compile/_helpers/test_expected_projection_scanning.py b/tests/unit/src/sqlbuild/compiler/compile/_helpers/test_expected_projection_scanning.py deleted file mode 100644 index 430e299ee2..0000000000 --- a/tests/unit/src/sqlbuild/compiler/compile/_helpers/test_expected_projection_scanning.py +++ /dev/null @@ -1,191 +0,0 @@ -"""Characterisation tests for the top-level scanners behind SQL-test expected projections.""" - -from __future__ import annotations - -import pytest - -from sqlbuild.compiler.compile._helpers.sql_tests import core as sql_test_core -from sqlbuild.compiler.sql_analysis.main._split_set_operation_branches import ( - split_set_operation_branches, -) -from sqlbuild.compiler.sql_analysis.models import SqlLexicalSyntax -from tests.unit.src.sqlbuild.compiler.compile._helpers._test_types import ( - DialectSetOperationSplitTestCase, - ExpectedProjectionScanTestCase, -) - -_GENERIC_SQL_SYNTAX: SqlLexicalSyntax = SqlLexicalSyntax() - - -@pytest.mark.parametrize( - "test_case", - ( - ExpectedProjectionScanTestCase( - description="plain select splits on top-level commas", - sql="SELECT a, b AS c FROM orders", - expected_branches=("SELECT a, b AS c FROM orders",), - expected_select_list_end=len("SELECT a, b AS c "), - expected_commas=("SELECT a", "b AS c FROM orders"), - expected_alias=None, - expected_contains_select_star=False, - ), - ExpectedProjectionScanTestCase( - description="quotes and comments hide keywords, commas and parentheses", - sql="SELECT 'x, FROM (' AS a, /* UNION , */ \"b)\" -- FROM\nFROM t", - expected_branches=("SELECT 'x, FROM (' AS a, /* UNION , */ \"b)\" -- FROM\nFROM t",), - expected_select_list_end=len("SELECT 'x, FROM (' AS a, /* UNION , */ \"b)\" -- FROM\n"), - expected_commas=("SELECT 'x, FROM (' AS a", '/* UNION , */ "b)" -- FROM\nFROM t'), - expected_alias=None, - expected_contains_select_star=False, - ), - ExpectedProjectionScanTestCase( - description="comments around branches and projections are not code", - sql="-- lead\nSELECT 1 AS a /* one */\n-- before\nUNION ALL /* after */ SELECT '--' AS a -- end", - expected_branches=("SELECT 1 AS a", "SELECT '--' AS a"), - expected_select_list_end=len( - "-- lead\nSELECT 1 AS a /* one */\n-- before\nUNION ALL /* after */ SELECT '--' AS a -- end" - ), - expected_commas=( - "-- lead\nSELECT 1 AS a /* one */\n-- before\nUNION ALL /* after */ SELECT '--' AS a -- end", - ), - expected_alias="a", - expected_contains_select_star=False, - ), - ExpectedProjectionScanTestCase( - description="nested parentheses hide commas, FROM and AS", - sql="SELECT f(a, (SELECT 1 AS x FROM y)) AS total", - expected_branches=("SELECT f(a, (SELECT 1 AS x FROM y)) AS total",), - expected_select_list_end=len("SELECT f(a, (SELECT 1 AS x FROM y)) AS total"), - expected_commas=("SELECT f(a, (SELECT 1 AS x FROM y)) AS total",), - expected_alias="total", - expected_contains_select_star=False, - ), - ExpectedProjectionScanTestCase( - description="union all splits branches", - sql="SELECT 1 AS a UNION ALL SELECT 2 AS a", - expected_branches=("SELECT 1 AS a", "SELECT 2 AS a"), - expected_select_list_end=len("SELECT 1 AS a UNION ALL SELECT 2 AS a"), - expected_commas=("SELECT 1 AS a UNION ALL SELECT 2 AS a",), - expected_alias="a", - expected_contains_select_star=False, - ), - ExpectedProjectionScanTestCase( - description="union distinct splits branches", - sql="SELECT 1 AS a UNION DISTINCT SELECT 2 AS a", - expected_branches=("SELECT 1 AS a", "SELECT 2 AS a"), - expected_select_list_end=len("SELECT 1 AS a UNION DISTINCT SELECT 2 AS a"), - expected_commas=("SELECT 1 AS a UNION DISTINCT SELECT 2 AS a",), - expected_alias="a", - expected_contains_select_star=False, - ), - ExpectedProjectionScanTestCase( - description="intersect and except split branches with quantifiers", - sql="SELECT 1 AS a INTERSECT ALL SELECT 2 AS a EXCEPT DISTINCT SELECT 3 AS a", - expected_branches=("SELECT 1 AS a", "SELECT 2 AS a", "SELECT 3 AS a"), - expected_select_list_end=len( - "SELECT 1 AS a INTERSECT ALL SELECT 2 AS a EXCEPT DISTINCT SELECT 3 AS a" - ), - expected_commas=( - "SELECT 1 AS a INTERSECT ALL SELECT 2 AS a EXCEPT DISTINCT SELECT 3 AS a", - ), - expected_alias="a", - expected_contains_select_star=False, - ), - ExpectedProjectionScanTestCase( - description="star except modifier is not a set operation", - sql="SELECT t.* EXCEPT (status), exceptional FROM t", - expected_branches=("SELECT t.* EXCEPT (status), exceptional FROM t",), - expected_select_list_end=len("SELECT t.* EXCEPT (status), exceptional "), - expected_commas=("SELECT t.* EXCEPT (status)", "exceptional FROM t"), - expected_alias=None, - expected_contains_select_star=False, - ), - ExpectedProjectionScanTestCase( - description="backtick identifiers are quoted", - sql="SELECT `a, b` AS `c`, * FROM t", - expected_branches=("SELECT `a, b` AS `c`, * FROM t",), - expected_select_list_end=len("SELECT `a, b` AS `c`, * "), - expected_commas=("SELECT `a, b` AS `c`", "* FROM t"), - expected_alias=None, - expected_contains_select_star=False, - ), - ExpectedProjectionScanTestCase( - description="select star inside a subquery is detected", - sql="SELECT a FROM (SELECT * FROM t)", - expected_branches=("SELECT a FROM (SELECT * FROM t)",), - expected_select_list_end=len("SELECT a "), - expected_commas=("SELECT a FROM (SELECT * FROM t)",), - expected_alias=None, - expected_contains_select_star=True, - ), - ), - ids=lambda case: case.description, -) -def test_given_expected_cte_sql_when_scanning_top_level_then_results_are_characterised( - test_case: ExpectedProjectionScanTestCase, -) -> None: - assert ( - split_set_operation_branches(sql=test_case.sql, context="SQL test") - == test_case.expected_branches - ) - assert ( - sql_test_core._find_select_list_end(sql=test_case.sql, start=0, syntax=_GENERIC_SQL_SYNTAX) - == test_case.expected_select_list_end - ) - assert ( - sql_test_core._split_top_level_commas(raw_value=test_case.sql, syntax=_GENERIC_SQL_SYNTAX) - == test_case.expected_commas - ) - assert ( - sql_test_core._extract_as_alias(expression=test_case.sql, syntax=_GENERIC_SQL_SYNTAX) - == test_case.expected_alias - ) - assert sql_test_core._contains_select_star(sql=test_case.sql, syntax=_GENERIC_SQL_SYNTAX) is ( - test_case.expected_contains_select_star - ) - - -@pytest.mark.parametrize( - "test_case", - ( - DialectSetOperationSplitTestCase( - description="generic rules end a block comment at the first close", - sql="SELECT 1 AS a /* x /* UNION ALL */ SELECT 9 AS b */ UNION ALL SELECT 2 AS a", - syntax=None, - expected_branches=( - "SELECT 1 AS a /* x /* UNION ALL */ SELECT 9 AS b */", - "SELECT 2 AS a", - ), - ), - DialectSetOperationSplitTestCase( - description="nested block comments hide a set operation", - sql="SELECT 1 AS a /* x /* UNION ALL */ SELECT 9 AS b */ UNION ALL SELECT 2 AS a", - syntax=SqlLexicalSyntax(nested_block_comments=True), - expected_branches=("SELECT 1 AS a", "SELECT 2 AS a"), - ), - DialectSetOperationSplitTestCase( - description="dialect line comment prefixes hide a set operation", - sql="SELECT 1 AS a # UNION ALL SELECT 9 AS b\nUNION ALL SELECT 2 AS a # end", - syntax=SqlLexicalSyntax(line_comment_prefixes=frozenset({"--", "#"})), - expected_branches=("SELECT 1 AS a", "SELECT 2 AS a"), - ), - DialectSetOperationSplitTestCase( - description="backslash escapes keep a quote open", - sql="SELECT 'it\\' UNION ALL SELECT 9' AS a UNION ALL SELECT 'x' AS a", - syntax=SqlLexicalSyntax(backslash_escape_quotes=frozenset({"'"})), - expected_branches=("SELECT 'it\\' UNION ALL SELECT 9' AS a", "SELECT 'x' AS a"), - ), - ), - ids=lambda case: case.description, -) -def test_given_dialect_lexical_syntax_when_splitting_set_operations_then_follows_dialect_rules( - test_case: DialectSetOperationSplitTestCase, -) -> None: - assert ( - split_set_operation_branches(sql=test_case.sql, context="SQL test", syntax=test_case.syntax) - == test_case.expected_branches - ) - - -if __name__ == "__main__": - pytest.main([__file__, "-vv"]) diff --git a/tests/unit/src/sqlbuild/compiler/compile/_helpers/test_native_sql_test_extraction.py b/tests/unit/src/sqlbuild/compiler/compile/_helpers/test_native_sql_test_extraction.py index 9a0672b680..ecbbe31173 100644 --- a/tests/unit/src/sqlbuild/compiler/compile/_helpers/test_native_sql_test_extraction.py +++ b/tests/unit/src/sqlbuild/compiler/compile/_helpers/test_native_sql_test_extraction.py @@ -9,15 +9,17 @@ from sqlbuild.adapters.snowflake.classes.snowflake_adapter import SnowflakeAdapter from sqlbuild.compiler.compile._helpers.sql_tests.core import ( extract_assertion_target_model_names, - extract_sql_test_ctes, ) -from sqlbuild.compiler.compile._helpers.sql_tests.native import extract_expanded_sql_tests +from sqlbuild.compiler.compile._helpers.sql_tests.native import ( + extract_expanded_sql_tests, + extract_unexpanded_sql_test, +) from sqlbuild.compiler.compile.models import CompileModelSqlTestCtes, CompileSqlTestCtes from sqlbuild.compiler.compile.types import SqlTestMode from sqlbuild.compiler.sql_analysis.models import SqlLexicalSyntax from tests.unit.src.sqlbuild.compiler.compile._helpers._test_types import ( DialectCteScanTestCase, - ExpectedBooleanTestCase, + ExpectedMessageTestCase, NativeSqlTestExtractionParityTestCase, ) @@ -97,90 +99,67 @@ ), ids=lambda case: case.description, ) -def test_given_expanded_sql_tests_when_native_batch_extracting_then_matches_reference_semantics( +def test_given_macro_free_sql_tests_when_extracting_before_and_after_expansion_then_payloads_match( test_case: NativeSqlTestExtractionParityTestCase, ) -> None: - expected: CompileSqlTestCtes = extract_sql_test_ctes( + expanded: tuple[CompileSqlTestCtes, ...] = extract_expanded_sql_tests( + tests=((test_case.sql, "tests/unit/example.sql", test_case.mode),), syntax=_GENERIC_SQL_SYNTAX, + ) + unexpanded: tuple[CompileSqlTestCtes, bool] = extract_unexpanded_sql_test( sql=test_case.sql, file_label="tests/unit/example.sql", mode=test_case.mode, - ) - - actual: tuple[CompileSqlTestCtes, ...] = extract_expanded_sql_tests( - tests=((test_case.sql, "tests/unit/example.sql", test_case.mode),), syntax=_GENERIC_SQL_SYNTAX, ) - assert (actual == (expected,)) is test_case.expected_matches - - -@pytest.mark.parametrize( - "test_case", - [ExpectedBooleanTestCase(description="cross-check diagnostic matches", expected_result=True)], - ids=lambda case: case.description, -) -def test_given_cross_check_dependency_when_native_batch_extracting_then_matches_reference_diagnostic( - test_case: ExpectedBooleanTestCase, -) -> None: - sql: str = ( - "WITH __source__raw_orders AS (SELECT 1 AS order_id), " - "__expected__orders AS (SELECT 1 AS order_id), " - "helper AS (SELECT order_id FROM __expected__orders), " - "__assert__same AS (SELECT order_id FROM helper) SELECT 1" - ) - with pytest.raises(ValueError) as reference_error: - extract_sql_test_ctes( - syntax=_GENERIC_SQL_SYNTAX, - sql=sql, - file_label="tests/unit/example.sql", - mode=SqlTestMode.MODEL, - ) - - with pytest.raises(ValueError) as native_error: - extract_expanded_sql_tests( - tests=((sql, "tests/unit/example.sql", SqlTestMode.MODEL),), - syntax=_GENERIC_SQL_SYNTAX, - ) - - assert (str(native_error.value) == str(reference_error.value)) is test_case.expected_result + assert (expanded == (unexpanded[0],), unexpanded[1]) == (test_case.expected_matches, False) @pytest.mark.parametrize( "test_case", [ - ExpectedBooleanTestCase( - description="comma-separated cross-check diagnostic matches", - expected_result=True, - ) + ExpectedMessageTestCase( + description="a check reading an expected CTE through a helper", + value=( + "WITH __source__raw_orders AS (SELECT 1 AS order_id), " + "__expected__orders AS (SELECT 1 AS order_id), " + "helper AS (SELECT order_id FROM __expected__orders), " + "__assert__same AS (SELECT order_id FROM helper) SELECT 1" + ), + expected_message=( + "SQL test 'tests/unit/example.sql' check CTE '__assert__same' must not depend on " + "'__expected__orders' through 'helper'; expected results and assertions must be " + "independent" + ), + ), + ExpectedMessageTestCase( + description="a check joining an expected CTE in a comma-separated FROM", + value=( + "WITH __source__orders AS (SELECT 1 AS id), " + "__expected__orders AS (SELECT 1 AS id), " + "__assert__valid AS (" + "SELECT expected.id FROM __source__orders source, __expected__orders expected" + ") SELECT 1" + ), + expected_message=( + "SQL test 'tests/unit/example.sql' check CTE '__assert__valid' must not depend on " + "'__expected__orders'; expected results and assertions must be independent" + ), + ), ], ids=lambda case: case.description, ) -def test_given_comma_separated_cross_check_dependency_when_extracting_then_native_matches_reference_diagnostic( - test_case: ExpectedBooleanTestCase, +def test_given_cross_check_dependency_when_extracting_then_independence_error_is_raised( + test_case: ExpectedMessageTestCase, ) -> None: - sql: str = ( - "WITH __source__orders AS (SELECT 1 AS id), " - "__expected__orders AS (SELECT 1 AS id), " - "__assert__valid AS (" - "SELECT expected.id FROM __source__orders source, __expected__orders expected" - ") SELECT 1" - ) - with pytest.raises(ValueError) as reference_error: - extract_sql_test_ctes( - syntax=_GENERIC_SQL_SYNTAX, - sql=sql, - file_label="tests/unit/example.sql", - mode=SqlTestMode.MODEL, - ) - with pytest.raises(ValueError) as native_error: extract_expanded_sql_tests( - tests=((sql, "tests/unit/example.sql", SqlTestMode.MODEL),), + tests=((test_case.value, "tests/unit/example.sql", SqlTestMode.MODEL),), syntax=_GENERIC_SQL_SYNTAX, ) - assert (str(native_error.value) == str(reference_error.value)) is test_case.expected_result + assert str(native_error.value) == test_case.expected_message @pytest.mark.parametrize( diff --git a/tests/unit/src/sqlbuild/compiler/compile/_helpers/test_set_operation_expected_ctes.py b/tests/unit/src/sqlbuild/compiler/compile/_helpers/test_set_operation_expected_ctes.py index d81aede976..c12fa0d171 100644 --- a/tests/unit/src/sqlbuild/compiler/compile/_helpers/test_set_operation_expected_ctes.py +++ b/tests/unit/src/sqlbuild/compiler/compile/_helpers/test_set_operation_expected_ctes.py @@ -6,15 +6,15 @@ import pytest -from sqlbuild.compiler.compile._helpers.sql_tests.core import classify_sql_test_ctes from sqlbuild.compiler.compile.exceptions import CompileInputError -from sqlbuild.compiler.compile.models import CompileSqlTestCte, CompileSqlTestCtes +from sqlbuild.compiler.compile.models import CompileSqlTestCtes from sqlbuild.compiler.compile.types import SqlTestMode from sqlbuild.compiler.sql_analysis.models import SqlLexicalSyntax from tests.unit.src.sqlbuild.compiler.compile._helpers._test_types import ( SetOperationExpectedKind, SetOperationExpectedTestCase, ) +from tests.unit.src.sqlbuild.compiler.compile._helpers.helpers import extract_test_ctes _GENERIC_SQL_SYNTAX: SqlLexicalSyntax = SqlLexicalSyntax() @@ -93,16 +93,13 @@ def test_given_set_operation_expected_cte_when_classifying_then_it_is_accepted( test_case: SetOperationExpectedTestCase, ) -> None: - ctes: tuple[CompileSqlTestCte, ...] = ( - *( - CompileSqlTestCte(name=name, sql_body=body) - for name, body in test_case.kind.support_ctes - ), - CompileSqlTestCte(name=test_case.kind.expected_cte_name, sql_body=test_case.sql), + ctes: tuple[tuple[str, str], ...] = ( + *test_case.kind.support_ctes, + (test_case.kind.expected_cte_name, test_case.sql), ) - classified: CompileSqlTestCtes = classify_sql_test_ctes( - ctes=ctes, file_label=_FILE, mode=test_case.kind.mode, syntax=_GENERIC_SQL_SYNTAX + classified: CompileSqlTestCtes = extract_test_ctes( + ctes=ctes, file_label=_FILE, mode=test_case.kind.mode ) assert type(classified.payload).__name__ == test_case.expected_payload_type @@ -124,18 +121,13 @@ def test_given_set_operation_expected_cte_when_classifying_then_it_is_accepted( def test_given_invalid_set_operation_branch_when_classifying_then_it_names_the_expected_cte( test_case: SetOperationExpectedTestCase, ) -> None: - ctes: tuple[CompileSqlTestCte, ...] = ( - *( - CompileSqlTestCte(name=name, sql_body=body) - for name, body in test_case.kind.support_ctes - ), - CompileSqlTestCte(name=test_case.kind.expected_cte_name, sql_body=test_case.sql), + ctes: tuple[tuple[str, str], ...] = ( + *test_case.kind.support_ctes, + (test_case.kind.expected_cte_name, test_case.sql), ) with pytest.raises(CompileInputError) as error_info: - _ = classify_sql_test_ctes( - ctes=ctes, file_label=_FILE, mode=test_case.kind.mode, syntax=_GENERIC_SQL_SYNTAX - ) + _ = extract_test_ctes(ctes=ctes, file_label=_FILE, mode=test_case.kind.mode) assert str(error_info.value) == test_case.expected_error_template.format( file=_FILE, cte=test_case.kind.expected_cte_name diff --git a/tests/unit/src/sqlbuild/compiler/compile/_helpers/test_sql_analysis_tests.py b/tests/unit/src/sqlbuild/compiler/compile/_helpers/test_sql_analysis_tests.py deleted file mode 100644 index c29f7d81c8..0000000000 --- a/tests/unit/src/sqlbuild/compiler/compile/_helpers/test_sql_analysis_tests.py +++ /dev/null @@ -1,134 +0,0 @@ -from __future__ import annotations - -import pytest - -pytest.importorskip("polyglot_sql") - -from sqlbuild.compiler.compile._helpers.analysis.tests import ( # noqa: E402 - extract_expected_branch_column_names_with_sql_analysis, -) -from sqlbuild.compiler.sql_analysis.models import SqlLexicalSyntax # noqa: E402 -from tests.unit.src.sqlbuild.compiler.compile._helpers._test_types import ( # noqa: E402 - ExtractSqlAnalysisExpectedBranchesErrorTestCase, - ExtractSqlAnalysisExpectedBranchesTestCase, -) - - -@pytest.mark.parametrize( - "test_case", - [ - ExtractSqlAnalysisExpectedBranchesTestCase( - description="extracts aliases from parenthesized union branches", - sql=""" - SELECT CAST(1 AS INTEGER) AS order_id, CAST('paid' AS VARCHAR) AS status - UNION ALL - (SELECT CAST(2 AS INTEGER) AS order_id, CAST('created' AS VARCHAR) AS status) - """.strip(), - expected_branch_column_names=( - ("order_id", "status"), - ("order_id", "status"), - ), - ), - ExtractSqlAnalysisExpectedBranchesTestCase( - description="extracts bare column names from select projections", - sql="SELECT order_id, status FROM expected_rows", - expected_branch_column_names=(("order_id", "status"),), - ), - ExtractSqlAnalysisExpectedBranchesTestCase( - description="fallback ignores an apostrophe in a line comment", - sql="SELECT 1 AS order_id; -- don't mix\nUNION ALL SELECT 2 AS order_id", - expected_branch_column_names=(("order_id",), ("order_id",)), - ), - ExtractSqlAnalysisExpectedBranchesTestCase( - description="fallback ignores a set operation inside a block comment", - sql="SELECT 1 AS order_id; /* UNION ALL */ UNION ALL SELECT 2 AS order_id", - expected_branch_column_names=(("order_id",), ("order_id",)), - ), - ExtractSqlAnalysisExpectedBranchesTestCase( - description="fallback splits union distinct", - sql="SELECT 1 AS order_id; UNION DISTINCT SELECT 2 AS order_id", - expected_branch_column_names=(("order_id",), ("order_id",)), - ), - ExtractSqlAnalysisExpectedBranchesTestCase( - description="fallback keeps doubled quotes inside one string", - sql="SELECT 1 AS order_id; UNION SELECT 'it''s UNION' AS order_id", - expected_branch_column_names=(("order_id",), ("order_id",)), - ), - ExtractSqlAnalysisExpectedBranchesTestCase( - description="parsed intersect and except return every branch in order", - sql=( - "SELECT 1 AS order_id UNION SELECT 2 AS order_id EXCEPT ALL " - "SELECT 3 AS other_id INTERSECT DISTINCT SELECT 4 AS extra_id" - ), - expected_branch_column_names=( - ("order_id",), - ("order_id",), - ("other_id",), - ("extra_id",), - ), - ), - ExtractSqlAnalysisExpectedBranchesTestCase( - description="fallback splits intersect and except with quantifiers", - sql=( - "SELECT 1 AS order_id; INTERSECT ALL SELECT 2 AS order_id " - "EXCEPT DISTINCT SELECT 3 AS other_id" - ), - expected_branch_column_names=(("order_id",), ("order_id",), ("other_id",)), - ), - ExtractSqlAnalysisExpectedBranchesTestCase( - description="fallback defers to textual analysis when a branch cannot parse", - sql='SELECT __udf("net_amount")(1) AS amount UNION ALL SELECT 2 AS amount', - expected_branch_column_names=None, - ), - ], - ids=lambda case: case.description, -) -def test_given_sql_analysis_available_when_extracting_expected_branches_then_it_returns_names( - test_case: ExtractSqlAnalysisExpectedBranchesTestCase, -) -> None: - branch_column_names: tuple[tuple[str, ...], ...] | None = ( - extract_expected_branch_column_names_with_sql_analysis( - sql=test_case.sql, - file_label="tests/unit/orders.sql", - syntax=SqlLexicalSyntax(), - ) - ) - - assert branch_column_names == test_case.expected_branch_column_names - - -@pytest.mark.parametrize( - "test_case", - [ - ExtractSqlAnalysisExpectedBranchesErrorTestCase( - description="raises when a non trivial projection lacks an alias", - sql="SELECT CAST(1 AS INTEGER), CAST('paid' AS VARCHAR) AS status", - expected_error_fragment="must alias every non-trivial __expected__ projection", - ), - ExtractSqlAnalysisExpectedBranchesErrorTestCase( - description="raises when expected branch is not a select query", - sql="SELECT 1 AS order_id UNION ALL VALUES (2)", - expected_error_fragment="set-operation branch as a SELECT query", - ), - ExtractSqlAnalysisExpectedBranchesErrorTestCase( - description="fallback rejects a values branch after except", - sql="SELECT 1 AS order_id; EXCEPT VALUES (2)", - expected_error_fragment="set-operation branch as a SELECT query", - ), - ExtractSqlAnalysisExpectedBranchesErrorTestCase( - description="raises when expected select uses star projection", - sql="SELECT * FROM expected_rows", - expected_error_fragment=r"must not use SELECT \* in __expected__ CTEs", - ), - ], - ids=lambda case: case.description, -) -def test_given_invalid_sql_analysis_expected_branches_when_extracting_then_it_raises_clear_errors( - test_case: ExtractSqlAnalysisExpectedBranchesErrorTestCase, -) -> None: - with pytest.raises(ValueError, match=test_case.expected_error_fragment): - extract_expected_branch_column_names_with_sql_analysis( - sql=test_case.sql, - file_label="tests/unit/orders.sql", - syntax=SqlLexicalSyntax(), - ) diff --git a/tests/unit/src/sqlbuild/compiler/compile/_helpers/test_sql_test_authored_offsets.py b/tests/unit/src/sqlbuild/compiler/compile/_helpers/test_sql_test_authored_offsets.py new file mode 100644 index 0000000000..40ce08355c --- /dev/null +++ b/tests/unit/src/sqlbuild/compiler/compile/_helpers/test_sql_test_authored_offsets.py @@ -0,0 +1,82 @@ +"""Offsets in a discovered SQL test body map back to the exact authored file position.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from sqlbuild.compiler.compile._helpers.sql_tests.extraction_errors import authored_file_offset +from sqlbuild.compiler.discovery.main._parse_sql_test_file import parse_sql_test_file +from sqlbuild.compiler.discovery.models import DiscoveredSqlTestBlock, DiscoveredSqlTestFile +from tests.unit.src.sqlbuild.compiler.compile._helpers._test_types import AuthoredOffsetTestCase + +_PATH: Path = Path("tests/unit/orders.sql") + + +@pytest.mark.parametrize( + "test_case", + [ + AuthoredOffsetTestCase( + description="flush-left body after a comment that repeats the marker", + contents="TEST ();\n-- zz_mark is named here\nWITH\nzz_mark AS (SELECT 1 AS a)\n", + marker="zz_mark AS", + expected_line_column=(4, 1), + ), + AuthoredOffsetTestCase( + description="indented body with a tab before the marker", + contents="TEST ();\n WITH\n h AS (SELECT 1 AS a),\n \tzz_mark AS (SELECT 1)\n", + marker="zz_mark", + expected_line_column=(4, 6), + ), + AuthoredOffsetTestCase( + description="tab inside a line before the marker", + contents="TEST ();\nWITH\nh AS (SELECT 1\t AS a, 'é' AS zz_mark)\n", + marker="zz_mark", + expected_line_column=(3, 30), + ), + AuthoredOffsetTestCase( + description="second block with blank lines after its header", + contents=( + 'TEST (name "one");\nWITH a AS (SELECT 1)\n;\nTEST (name "two");\n\n\n' + " WITH\n b AS (SELECT zz_mark)\n" + ), + marker="zz_mark", + expected_line_column=(8, 16), + ), + AuthoredOffsetTestCase( + description="first body line indented on the header line", + contents="TEST (); WITH zz_mark AS (SELECT 1)\n , b AS (SELECT 2)\n", + marker="zz_mark", + expected_line_column=(1, 17), + ), + ], + ids=lambda case: case.description, +) +def test_given_discovered_block_when_mapping_body_offset_then_points_at_authored_text( + test_case: AuthoredOffsetTestCase, +) -> None: + blocks: tuple[DiscoveredSqlTestBlock, ...] = parse_sql_test_file( + contents=test_case.contents, file_path=_PATH + ) + test_block: DiscoveredSqlTestBlock = blocks[-1] + test_file: DiscoveredSqlTestFile = DiscoveredSqlTestFile( + file_path=_PATH, relative_path=_PATH, contents=test_case.contents, blocks=blocks + ) + body_offset: int = test_block.sql_body.rindex(test_case.marker) + + offset: int | None = authored_file_offset( + test_file=test_file, test_block=test_block, body_offset=body_offset + ) + + contents: str = test_case.contents + resolved: int = offset or 0 + assert ( + contents[resolved : resolved + len(test_case.marker)], + contents.count("\n", 0, resolved) + 1, + resolved - contents.rfind("\n", 0, resolved), + ) == (test_case.marker, *test_case.expected_line_column) + + +if __name__ == "__main__": + pytest.main([__file__, "-vv"]) diff --git a/tests/unit/src/sqlbuild/compiler/compile/_helpers/test_tests.py b/tests/unit/src/sqlbuild/compiler/compile/_helpers/test_tests.py index a134894e1d..7fccaea116 100644 --- a/tests/unit/src/sqlbuild/compiler/compile/_helpers/test_tests.py +++ b/tests/unit/src/sqlbuild/compiler/compile/_helpers/test_tests.py @@ -3,15 +3,14 @@ import pytest from sqlbuild.compiler.compile._helpers.sql_tests.core import ( - CompileSqlTestCtes, complete_omitted_ceremonial_select, - extract_sql_test_ctes, extract_sql_test_expected_model_names, ) from sqlbuild.compiler.compile.exceptions import CompileInputError from sqlbuild.compiler.compile.models import ( CompileDirectLogicSqlTestCtes, CompileModelSqlTestCtes, + CompileSqlTestCtes, ) from sqlbuild.compiler.compile.types import SqlTestMode from sqlbuild.compiler.sql_analysis.models import SqlLexicalSyntax @@ -21,6 +20,7 @@ ExtractSqlTestCtesErrorTestCase, ExtractSqlTestCtesTestCase, ) +from tests.unit.src.sqlbuild.compiler.compile._helpers.helpers import extract_test_sql _GENERIC_SQL_SYNTAX: SqlLexicalSyntax = SqlLexicalSyntax() @@ -311,19 +311,6 @@ expected_mock_source_names=("raw_orders",), expected_expected_model_names=("orders",), ), - ExtractSqlTestCtesTestCase( - description="extracts model test ctes with sql_analysis fallback syntax", - sql=""" - WITH - "__source__raw_orders" AS MATERIALIZED (SELECT 1 AS order_id), - "__expected__orders" AS (SELECT order_id FROM "__source__raw_orders") - SELECT 1 - """.strip(), - expected_authored_cte_names=("__source__raw_orders",), - expected_mock_model_names=(), - expected_mock_source_names=("raw_orders",), - expected_expected_model_names=("orders",), - ), ExtractSqlTestCtesTestCase( description="allows cast expressions with explicit aliases in expected ctes", sql=""" @@ -365,11 +352,8 @@ def test_given_model_sql_test_cte_variants_when_extracting_then_it_returns_expected_roles( test_case: ExtractSqlTestCtesTestCase, ) -> None: - extracted_ctes: CompileSqlTestCtes = extract_sql_test_ctes( - syntax=_GENERIC_SQL_SYNTAX, - sql=test_case.sql, - file_label="tests/unit/orders.sql", - mode=test_case.mode, + extracted_ctes: CompileSqlTestCtes = extract_test_sql( + sql=test_case.sql, file_label="tests/unit/orders.sql", mode=test_case.mode ) assert isinstance(extracted_ctes.payload, CompileModelSqlTestCtes) @@ -466,11 +450,8 @@ def test_given_model_sql_test_cte_variants_when_extracting_then_it_returns_expec def test_given_direct_logic_sql_test_cte_variants_when_extracting_then_it_returns_expected_roles( test_case: ExtractSqlTestCtesTestCase, ) -> None: - extracted_ctes: CompileSqlTestCtes = extract_sql_test_ctes( - syntax=_GENERIC_SQL_SYNTAX, - sql=test_case.sql, - file_label="tests/unit/orders.sql", - mode=test_case.mode, + extracted_ctes: CompileSqlTestCtes = extract_test_sql( + sql=test_case.sql, file_label="tests/unit/orders.sql", mode=test_case.mode ) assert isinstance(extracted_ctes.payload, CompileDirectLogicSqlTestCtes) @@ -677,7 +658,26 @@ def test_given_direct_logic_sql_test_cte_variants_when_extracting_then_it_return test_result AS (SELECT 1) SELECT 2 UNION ALL SELECT * FROM test_result """.strip(), - expected_error_fragment="expected a CTE name", + expected_error_fragment='CTE name "__source__raw_orders" must be an unquoted identifier', + ), + ExtractSqlTestCtesErrorTestCase( + description="raises for quoted CTE names instead of re-parsing the test", + sql=""" + WITH + "__source__raw_orders" AS MATERIALIZED (SELECT 1 AS order_id), + "__expected__orders" AS (SELECT order_id FROM "__source__raw_orders") + SELECT 1 + """.strip(), + expected_error_fragment='CTE name "__source__raw_orders" must be an unquoted identifier', + ), + ExtractSqlTestCtesErrorTestCase( + description="raises for materialized CTEs", + sql=""" + WITH __source__raw_orders AS MATERIALIZED (SELECT 1 AS order_id), + __expected__orders AS (SELECT order_id FROM __source__raw_orders) + SELECT 1 + """.strip(), + expected_error_fragment="CTE '__source__raw_orders' must use AS \\(\\.\\.\\.\\)", ), ExtractSqlTestCtesErrorTestCase( description="raises when expected cte omits target name", @@ -877,12 +877,7 @@ def test_given_invalid_sql_test_cte_variants_when_extracting_then_it_raises_clea test_case: ExtractSqlTestCtesErrorTestCase, ) -> None: with pytest.raises(ValueError, match=test_case.expected_error_fragment): - extract_sql_test_ctes( - syntax=_GENERIC_SQL_SYNTAX, - sql=test_case.sql, - file_label="tests/unit/orders.sql", - mode=test_case.mode, - ) + extract_test_sql(sql=test_case.sql, file_label="tests/unit/orders.sql", mode=test_case.mode) @pytest.mark.parametrize( @@ -920,9 +915,7 @@ def test_given_malformed_sql_test_ctes_when_extracting_then_messages_name_the_sq test_case: CteScannerMessageTestCase, ) -> None: with pytest.raises(CompileInputError) as error_info: - _ = extract_sql_test_ctes( - sql=test_case.sql, file_label="tests/unit/orders.sql", syntax=_GENERIC_SQL_SYNTAX - ) + _ = extract_test_sql(sql=test_case.sql, file_label="tests/unit/orders.sql") assert str(error_info.value) == test_case.expected_message diff --git a/website/src/content/docs/docs/concepts/testing.mdx b/website/src/content/docs/docs/concepts/testing.mdx index 127424023a..85c9691352 100644 --- a/website/src/content/docs/docs/concepts/testing.mdx +++ b/website/src/content/docs/docs/concepts/testing.mdx @@ -111,8 +111,24 @@ and `//` line comments on Snowflake. An `__expected__` CTE compares only the columns it lists, matched by name, so column order does not matter and unlisted model columns are ignored. Row counts are always compared. Listing a -column the model does not output is a test error. When the expected CTE's columns are not explicit -(for example `SELECT *` from another CTE), SQLBuild compares every column by position. +column the model does not output is a test error. + +SQLBuild reads test CTEs strictly, the same way on every compiler engine, and rejects other forms +with a compile error that names the CTE: + +- A CTE name is an unquoted identifier of ASCII letters, digits and underscores that starts with a + letter or underscore. Quoted names such as `"recent orders"` or `` `recent orders` ``, and names + with `$` or non-ASCII characters, are errors that point at the name. +- `WITH`, `AS`, `SELECT` and the other keywords match ASCII letters in any case. +- Every column of an expected CTE (`__expected__`, `__macro_expected__`, `__udf_expected__` + or `__table_fn_expected__`) needs a name. A bare or qualified column reference such as `order_id` + or `o.order_id` names itself; any other expression, including `amount IS NULL` or + `CASE ... END`, needs an alias (` AS `). An alias without `AS`, such as + `1 order_id`, also names the column. +- An expected CTE is a plain `SELECT`, optionally combined with `UNION`, `INTERSECT` or `EXCEPT`. + Branches wrapped in parentheses and a `WITH` inside the CTE are errors; put shared rows in a + helper CTE instead. `SELECT *`, `SELECT DISTINCT *` and `t.*` are errors too, except + `SELECT * FROM __empty_fixture()` in a contracted `__expected__` CTE. Helper CTEs can read other helpers and mocks by CTE name, such as `__ref__stg_orders`. Shared expected rows can therefore live in one helper that both an `__expected__` CTE and an assertion @@ -581,6 +597,11 @@ Each mode has strict CTE validation: CTE prefixes from other modes are not allowed. For example, `__source__` in a macro test or `__macro_actual__` in a test declared `TEST (mode model)` will produce a clear error pointing you to the right mode. Expected CTEs must not call macros, UDFs, or table functions - they should be independent, inspectable expected data. +Helper CTEs follow the same rule in every mode: only the actual CTE calls the macro, UDF or table +function under test, so a helper of a macro test must not call `__udf()` or `__table_fn()` either. +A malformed reference call in a helper or expected CTE, such as `__source("raw", "orders")`, is a +P012 error at the call. + ## Multiple tests per file A single test file can contain multiple `TEST()` blocks. A test's name is its `name`, or the file