From 94acb9dcd150698bce693ab85b7c5429e7c05b82 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Wed, 7 Oct 2026 16:10:22 +0100 Subject: [PATCH 1/3] feat(native): add the general SQL reference scan with table-function call arguments Port Python's general reference extraction to sqlbuild-sqltext: every reference kind including __table_fn with its call-argument count, under each adapter's lexical rules, with Python's exact error messages for argument counts, empty arguments, malformed names and unclosed text. A non-ASCII character that decides the outcome, or an unsupported line comment prefix, defers the text to Python. --- crates/README.md | 2 +- crates/sqlbuild-sqltext/src/lib.rs | 1 + .../src/sql_references/_helpers/mod.rs | 1 + .../src/sql_references/_helpers/scan.rs | 310 ++++++++++++++++++ .../src/sql_references/constants.rs | 29 ++ .../main/extract_sql_references.rs | 22 ++ .../src/sql_references/main/mod.rs | 1 + .../src/sql_references/mod.rs | 8 + .../src/sql_references/models.rs | 23 ++ .../src/sql_references/tests.rs | 6 + .../src/sql_references/tests/helpers.rs | 58 ++++ .../tests/test_extract_sql_references.rs | 188 +++++++++++ .../src/sql_references/tests/test_types.rs | 9 + .../src/sql_references/types.rs | 4 + 14 files changed, 661 insertions(+), 1 deletion(-) create mode 100644 crates/sqlbuild-sqltext/src/sql_references/_helpers/mod.rs create mode 100644 crates/sqlbuild-sqltext/src/sql_references/_helpers/scan.rs create mode 100644 crates/sqlbuild-sqltext/src/sql_references/constants.rs create mode 100644 crates/sqlbuild-sqltext/src/sql_references/main/extract_sql_references.rs create mode 100644 crates/sqlbuild-sqltext/src/sql_references/main/mod.rs create mode 100644 crates/sqlbuild-sqltext/src/sql_references/mod.rs create mode 100644 crates/sqlbuild-sqltext/src/sql_references/models.rs create mode 100644 crates/sqlbuild-sqltext/src/sql_references/tests.rs create mode 100644 crates/sqlbuild-sqltext/src/sql_references/tests/helpers.rs create mode 100644 crates/sqlbuild-sqltext/src/sql_references/tests/test_extract_sql_references.rs create mode 100644 crates/sqlbuild-sqltext/src/sql_references/tests/test_types.rs create mode 100644 crates/sqlbuild-sqltext/src/sql_references/types.rs diff --git a/crates/README.md b/crates/README.md index 2ca7240e3b..6993d60495 100644 --- a/crates/README.md +++ b/crates/README.md @@ -7,7 +7,7 @@ that can be tested and benchmarked with `cargo test` alone. | Crate | Responsibility | |---|---| | `sqlbuild-core` | Constants shared by several crates, the panic boundary that turns an unwinding native failure into a named compiler error, passive identity, diagnostic and SQL value types, text positions, and a JSON emitter whose output matches Python's `json.dumps` and `orjson` byte for byte. | -| `sqlbuild-sqltext` | Lexical SQL text without polyglot: comment, quote and parenthesis scanning, the rules quote policy, model header tokenization and matching, static variable substitution and static reference extraction. | +| `sqlbuild-sqltext` | Lexical SQL text without polyglot: comment, quote and parenthesis scanning, the rules quote policy, model header tokenization and matching, static variable substitution, static reference extraction, and the general reference scan with table-function call arguments under each adapter's lexical rules. | | `sqlbuild-config` | Configuration file reading without Python: `tomllib`-compatible TOML, PyYAML `safe_load`-compatible YAML 1.1, and typed project and local config readers for the fields discovery needs. Errors carry their kind and position; YAML outside the supported subset is an `Unsupported` error. | | `sqlbuild-discovery` | Native project discovery and the only implementation of the model, SQL test, scenario, source and schema file collections and the declaration layout: the shared directory walk with Python's glob and sort semantics, file reading with Python's newline handling and read errors, and parsing of authored files with Python's exact discovery messages. Results are plain data the Python discovery facade materialises. | | `sqlbuild-scopes` | Declaration scopes: the scope index with Python's record orders and diagnostics, declaration visibility, relationship grants, the scope lookup groups, and the dialect-aware scan of SQL tests and scenarios for their expected models. Anything it cannot reproduce exactly defers to Python. | diff --git a/crates/sqlbuild-sqltext/src/lib.rs b/crates/sqlbuild-sqltext/src/lib.rs index 1f02828348..74de9d7d28 100644 --- a/crates/sqlbuild-sqltext/src/lib.rs +++ b/crates/sqlbuild-sqltext/src/lib.rs @@ -4,4 +4,5 @@ pub mod compiler; mod constants; +pub mod sql_references; pub mod sql_scan; diff --git a/crates/sqlbuild-sqltext/src/sql_references/_helpers/mod.rs b/crates/sqlbuild-sqltext/src/sql_references/_helpers/mod.rs new file mode 100644 index 0000000000..49f9fa71a8 --- /dev/null +++ b/crates/sqlbuild-sqltext/src/sql_references/_helpers/mod.rs @@ -0,0 +1 @@ +pub(crate) mod scan; diff --git a/crates/sqlbuild-sqltext/src/sql_references/_helpers/scan.rs b/crates/sqlbuild-sqltext/src/sql_references/_helpers/scan.rs new file mode 100644 index 0000000000..b76bf9b2c1 --- /dev/null +++ b/crates/sqlbuild-sqltext/src/sql_references/_helpers/scan.rs @@ -0,0 +1,310 @@ +//! The general reference scan of Python `extract_sql_references`, reproduced exactly. + +use crate::sql_references::constants::{ + DBT_REFERENCE_KIND, NAME_QUOTE_BYTES, NON_CODE_START_BYTES, PAIRED_QUOTE_LENGTH, + PYTHON_ASCII_WHITESPACE, QUOTE_BYTES, REFERENCE_CONTEXT, REFERENCE_PREFIXES, + TABLE_FUNCTION_CALL_CONTEXT, TABLE_FUNCTION_REFERENCE_KIND, +}; +use crate::sql_references::models::SqlReference; +use crate::sql_references::types::ReferencePrefix; +use crate::sql_scan::main::dialect_non_code_end::dialect_non_code_end; +use crate::sql_scan::models::{LexicalSyntax, Unclosed}; + +/// Why the scan stopped before reaching the end of the SQL. +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum Stop { + /// Python raises `CompileInputError` with this message. + Failed(String), + /// A non-ASCII character decides the outcome, which needs Python's `str` classification. + Deferred, +} + +type Scan = Result; + +const NON_CODE_START: [bool; 256] = byte_table(NON_CODE_START_BYTES, false); +const REFERENCE_SCAN_START: [bool; 256] = byte_table(NON_CODE_START_BYTES, true); + +/// Mark `bytes`, and the underscore that starts every reference call when `underscore` is set. +const fn byte_table(bytes: &[u8], underscore: bool) -> [bool; 256] { + let mut table = [false; 256]; + let mut index = 0; + while index < bytes.len() { + table[bytes[index] as usize] = true; + index += 1; + } + table[b'_' as usize] = underscore; + table +} + +/// Return every reference in `sql` in authored order. +pub(crate) fn scan_references(sql: &[u8], syntax: &LexicalSyntax) -> Scan> { + let mut references: Vec = Vec::new(); + let mut index = 0; + while index < sql.len() { + if let Some(end) = non_code_end(sql, index, syntax, REFERENCE_CONTEXT)? { + index = end; + continue; + } + if sql[index] != b'_' { + index += 1; + while index < sql.len() && !REFERENCE_SCAN_START[usize::from(sql[index])] { + index += 1; + } + continue; + } + let Some(call) = REFERENCE_PREFIXES + .iter() + .find(|(prefix, _, _)| sql[index..].starts_with(prefix)) + else { + index += 1; + continue; + }; + let (reference, next) = parse_reference(sql, index, call, syntax)?; + references.push(reference); + index = next; + } + Ok(references) +} + +/// Parse the reference call starting at `start`, returning it and the offset after its name list. +fn parse_reference( + sql: &[u8], + start: usize, + call: &ReferencePrefix, + syntax: &LexicalSyntax, +) -> Scan<(SqlReference, usize)> { + let (prefix, kind, call_name) = *call; + let open = start + prefix.len() - 1; + let close = matching_paren(sql, open, syntax, REFERENCE_CONTEXT)?; + let arguments = split_arguments(sql, open + 1, close, syntax)?; + if kind == DBT_REFERENCE_KIND { + if !matches!(arguments.len(), 1 | 2) { + return Err(failed(format!( + "{call_name} must contain one name argument or package/name arguments" + ))); + } + if let [package, name] = arguments.as_slice() { + let package = reference_name(package, kind, call_name)?; + let name = reference_name(name, kind, call_name)?; + return Ok(( + SqlReference { + kind, + name, + package: Some(package), + call_argument_count: None, + }, + close + 1, + )); + } + } else if arguments.len() != 1 { + return Err(failed(format!( + "{call_name} must contain exactly one name argument" + ))); + } + let mut call_argument_count = None; + if kind == TABLE_FUNCTION_REFERENCE_KIND { + let suffix = python_whitespace_end(sql, close + 1)?; + if sql.get(suffix) != Some(&b'(') { + return Err(failed(format!( + "{call_name} must be followed by an argument list" + ))); + } + let suffix_close = matching_paren(sql, suffix, syntax, TABLE_FUNCTION_CALL_CONTEXT)?; + call_argument_count = Some(split_arguments(sql, suffix + 1, suffix_close, syntax)?.len()); + } + Ok(( + SqlReference { + kind, + name: reference_name(&arguments[0], kind, call_name)?, + package: None, + call_argument_count, + }, + close + 1, + )) +} + +/// Python `_split_top_level_arguments` over `sql[start..end]`: comments read as one space. +fn split_arguments( + sql: &[u8], + start: usize, + end: usize, + syntax: &LexicalSyntax, +) -> Scan>> { + let mut arguments: Vec> = Vec::new(); + let mut current: Vec = Vec::new(); + let mut depth = 0isize; + let mut saw_separator = false; + let mut index = start; + while index < end { + if let Some(non_code) = non_code_end(sql, index, syntax, REFERENCE_CONTEXT)? { + if non_code > end { + return Err(Stop::Deferred); + } + if QUOTE_BYTES.contains(&sql[index]) { + current.extend_from_slice(&sql[index..non_code]); + } else { + current.push(b' '); + } + index = non_code; + continue; + } + match sql[index] { + b'(' => depth += 1, + b')' => depth -= 1, + b',' if depth == 0 => { + let argument = python_strip(¤t)?; + if argument.is_empty() { + return Err(failed(format!( + "{REFERENCE_CONTEXT} contains an empty argument" + ))); + } + arguments.push(argument.to_vec()); + current.clear(); + saw_separator = true; + index += 1; + continue; + } + _ => {} + } + current.push(sql[index]); + index += 1; + } + let argument = python_strip(¤t)?; + if !argument.is_empty() { + arguments.push(argument.to_vec()); + } else if saw_separator { + return Err(failed(format!( + "{REFERENCE_CONTEXT} contains an empty argument" + ))); + } + Ok(arguments) +} + +/// Python `_parse_reference_name` for one stripped argument. +fn reference_name(argument: &[u8], kind: &str, call_name: &str) -> Scan { + let paired_quote = |quotes: &[u8]| { + argument.len() >= PAIRED_QUOTE_LENGTH + && argument[0] == argument[argument.len() - 1] + && quotes.contains(&argument[0]) + }; + if kind == TABLE_FUNCTION_REFERENCE_KIND && !paired_quote(b"\"") { + return Err(failed(format!( + "{call_name} name argument must be double quoted" + ))); + } + if paired_quote(NAME_QUOTE_BYTES) { + return utf8(&argument[1..argument.len() - 1]); + } + if identifier_validity(argument)? { + return utf8(argument); + } + Err(failed(format!( + "{call_name} name argument must be a quoted string or identifier" + ))) +} + +/// Python `value.replace("_", "a").isalnum() and value[0].isalpha()` for a non-empty value. +fn identifier_validity(value: &[u8]) -> Scan { + if value + .iter() + .any(|byte| byte.is_ascii() && !(byte.is_ascii_alphanumeric() || *byte == b'_')) + { + return Ok(false); + } + if value[0].is_ascii() && !value[0].is_ascii_alphabetic() { + return Ok(false); + } + if value.is_ascii() { + Ok(true) + } else { + Err(Stop::Deferred) + } +} + +/// Python `str.strip()`; a non-ASCII character at either edge may be Unicode whitespace. +fn python_strip(value: &[u8]) -> Scan<&[u8]> { + let start = value + .iter() + .position(|byte| !PYTHON_ASCII_WHITESPACE.contains(byte)) + .unwrap_or(value.len()); + let end = value + .iter() + .rposition(|byte| !PYTHON_ASCII_WHITESPACE.contains(byte)) + .map_or(start, |last| last + 1); + let stripped = &value[start..end]; + if stripped.first().is_some_and(|byte| !byte.is_ascii()) + || stripped.last().is_some_and(|byte| !byte.is_ascii()) + { + return Err(Stop::Deferred); + } + Ok(stripped) +} + +/// Python `_skip_whitespace`, which skips `str.isspace()` characters. +fn python_whitespace_end(sql: &[u8], start: usize) -> Scan { + let mut index = start; + while let Some(byte) = sql.get(index) { + if !byte.is_ascii() { + return Err(Stop::Deferred); + } + if !PYTHON_ASCII_WHITESPACE.contains(byte) { + break; + } + index += 1; + } + Ok(index) +} + +fn non_code_end( + sql: &[u8], + index: usize, + syntax: &LexicalSyntax, + context: &str, +) -> Scan> { + if !NON_CODE_START[usize::from(sql[index])] { + return Ok(None); + } + dialect_non_code_end(sql, index, syntax).map_err(|unclosed| unclosed_error(context, unclosed)) +} + +/// Python `find_matching_paren`, which only looks for comments and quotes at special characters. +fn matching_paren(sql: &[u8], open: usize, syntax: &LexicalSyntax, context: &str) -> Scan { + let mut depth = 1usize; + let mut index = open + 1; + while index < sql.len() { + match sql[index] { + b'(' => depth += 1, + b')' => { + depth -= 1; + if depth == 0 { + return Ok(index); + } + } + _ => { + if let Some(end) = non_code_end(sql, index, syntax, context)? { + index = end; + continue; + } + } + } + index += 1; + } + Err(unclosed_error(context, Unclosed::Parenthesis)) +} + +fn unclosed_error(context: &str, unclosed: Unclosed) -> Stop { + let construct = match unclosed { + Unclosed::BlockComment => "block comment", + Unclosed::Quote => "quoted string", + Unclosed::Parenthesis => "parenthesis", + }; + failed(format!("{context} contains an unclosed {construct}")) +} + +fn utf8(value: &[u8]) -> Scan { + String::from_utf8(value.to_vec()).map_err(|_| Stop::Deferred) +} + +fn failed(message: String) -> Stop { + Stop::Failed(message) +} diff --git a/crates/sqlbuild-sqltext/src/sql_references/constants.rs b/crates/sqlbuild-sqltext/src/sql_references/constants.rs new file mode 100644 index 0000000000..eeb3d80be3 --- /dev/null +++ b/crates/sqlbuild-sqltext/src/sql_references/constants.rs @@ -0,0 +1,29 @@ +//! Reference call prefixes and the contexts Python names in reference errors. + +use crate::sql_references::types::ReferencePrefix; + +/// Call prefixes with their kinds and error names, in Python's matching order. +pub(crate) const REFERENCE_PREFIXES: [ReferencePrefix; 6] = [ + (b"__dbt_ref(", "dbt_ref", "__dbt_ref"), + (b"__table_fn(", "table_fn", "__table_fn"), + (b"__source(", "source", "__source"), + (b"__seed(", "seed", "__seed"), + (b"__udf(", "udf", "__udf"), + (b"__ref(", "ref", "__ref"), +]; +pub(crate) const DBT_REFERENCE_KIND: &str = "dbt_ref"; +pub(crate) const TABLE_FUNCTION_REFERENCE_KIND: &str = "table_fn"; +pub(crate) const REFERENCE_CONTEXT: &str = "SQL reference"; +pub(crate) const TABLE_FUNCTION_CALL_CONTEXT: &str = "SQL table function call"; +/// Line comment prefixes Python's reference and parenthesis scans both stop at. +pub(crate) const SUPPORTED_LINE_COMMENT_PREFIXES: [&str; 3] = ["--", "//", "#"]; +/// Characters that may start a comment or quoted text under a supported syntax. +pub(crate) const NON_CODE_START_BYTES: &[u8] = b"-/#'\"`$"; +/// Non-code openers whose text an argument keeps; comments become one space. +pub(crate) const QUOTE_BYTES: &[u8] = b"'\"`$"; +/// Shortest quoted reference name: the opening and closing quote. +pub(crate) const PAIRED_QUOTE_LENGTH: usize = 2; +/// Quotes that delimit a reference name. +pub(crate) const NAME_QUOTE_BYTES: &[u8] = b"'\""; +/// Python `str.isspace()` for ASCII characters. +pub(crate) const PYTHON_ASCII_WHITESPACE: &[u8] = b" \t\n\x0b\x0c\r\x1c\x1d\x1e\x1f"; diff --git a/crates/sqlbuild-sqltext/src/sql_references/main/extract_sql_references.rs b/crates/sqlbuild-sqltext/src/sql_references/main/extract_sql_references.rs new file mode 100644 index 0000000000..65c55d7084 --- /dev/null +++ b/crates/sqlbuild-sqltext/src/sql_references/main/extract_sql_references.rs @@ -0,0 +1,22 @@ +//! Logical SQL references under one adapter's lexical rules, or a deferral to Python. + +use crate::sql_references::_helpers::scan::{Stop, scan_references}; +use crate::sql_references::constants::SUPPORTED_LINE_COMMENT_PREFIXES; +use crate::sql_references::models::ReferenceExtraction; +use crate::sql_scan::models::LexicalSyntax; + +/// Return the references Python `extract_sql_references` finds in `sql`, or its error message. +pub fn extract_sql_references(sql: &str, syntax: &LexicalSyntax) -> ReferenceExtraction { + if !syntax + .line_comment_prefixes + .iter() + .all(|prefix| SUPPORTED_LINE_COMMENT_PREFIXES.contains(&prefix.as_str())) + { + return ReferenceExtraction::Deferred; + } + match scan_references(sql.as_bytes(), syntax) { + Ok(references) => ReferenceExtraction::Extracted(references), + Err(Stop::Failed(message)) => ReferenceExtraction::Failed(message), + Err(Stop::Deferred) => ReferenceExtraction::Deferred, + } +} diff --git a/crates/sqlbuild-sqltext/src/sql_references/main/mod.rs b/crates/sqlbuild-sqltext/src/sql_references/main/mod.rs new file mode 100644 index 0000000000..7f3aa5abae --- /dev/null +++ b/crates/sqlbuild-sqltext/src/sql_references/main/mod.rs @@ -0,0 +1 @@ +pub mod extract_sql_references; diff --git a/crates/sqlbuild-sqltext/src/sql_references/mod.rs b/crates/sqlbuild-sqltext/src/sql_references/mod.rs new file mode 100644 index 0000000000..e68542ff38 --- /dev/null +++ b/crates/sqlbuild-sqltext/src/sql_references/mod.rs @@ -0,0 +1,8 @@ +pub(crate) mod _helpers; +pub(crate) mod constants; +pub mod main; +pub mod models; +pub(crate) mod types; + +#[cfg(test)] +mod tests; diff --git a/crates/sqlbuild-sqltext/src/sql_references/models.rs b/crates/sqlbuild-sqltext/src/sql_references/models.rs new file mode 100644 index 0000000000..0cfa935aa3 --- /dev/null +++ b/crates/sqlbuild-sqltext/src/sql_references/models.rs @@ -0,0 +1,23 @@ +//! Logical SQL references and the outcome of extracting them from one SQL text. + +/// One `__ref`, `__source`, `__seed`, `__udf`, `__dbt_ref` or `__table_fn` call. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SqlReference { + /// Python `SqlReferenceKind` value, such as `ref` or `table_fn`. + pub kind: &'static str, + pub name: String, + pub package: Option, + /// Top-level arguments of a table function's call suffix; `None` for other kinds. + pub call_argument_count: Option, +} + +/// The references of one SQL text, Python's error for it, or a deferral to Python. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ReferenceExtraction { + /// Every reference in authored order, exactly as Python extracts them. + Extracted(Vec), + /// The message of the `CompileInputError` Python raises for this text. + Failed(String), + /// A character or syntax rule the scan cannot classify exactly as Python does. + Deferred, +} diff --git a/crates/sqlbuild-sqltext/src/sql_references/tests.rs b/crates/sqlbuild-sqltext/src/sql_references/tests.rs new file mode 100644 index 0000000000..6c476d8cb9 --- /dev/null +++ b/crates/sqlbuild-sqltext/src/sql_references/tests.rs @@ -0,0 +1,6 @@ +#[path = "tests/test_extract_sql_references.rs"] +mod extract_sql_references; +#[path = "tests/helpers.rs"] +mod helpers; +#[path = "tests/test_types.rs"] +mod test_types; diff --git a/crates/sqlbuild-sqltext/src/sql_references/tests/helpers.rs b/crates/sqlbuild-sqltext/src/sql_references/tests/helpers.rs new file mode 100644 index 0000000000..2666a4b9a5 --- /dev/null +++ b/crates/sqlbuild-sqltext/src/sql_references/tests/helpers.rs @@ -0,0 +1,58 @@ +//! Lexical syntaxes and expected references for reference extraction tests. + +use crate::sql_references::models::{ReferenceExtraction, SqlReference}; +use crate::sql_scan::models::LexicalSyntax; + +pub(crate) fn generic() -> LexicalSyntax { + LexicalSyntax { + line_comment_prefixes: vec!["--".to_string()], + ..LexicalSyntax::default() + } +} + +pub(crate) fn backslash_hash_comments() -> LexicalSyntax { + LexicalSyntax { + backslash_escape_quotes: vec!["'".to_string(), "\"".to_string()], + triple_quoted_strings: true, + line_comment_prefixes: vec!["--".to_string(), "#".to_string()], + ..LexicalSyntax::default() + } +} + +pub(crate) fn unsupported_comments() -> LexicalSyntax { + LexicalSyntax { + line_comment_prefixes: vec!["--".to_string(), "%".to_string()], + ..LexicalSyntax::default() + } +} + +pub(crate) fn reference(kind: &'static str, name: &str) -> SqlReference { + SqlReference { + kind, + name: name.to_string(), + package: None, + call_argument_count: None, + } +} + +pub(crate) fn table_function(name: &str, call_argument_count: usize) -> SqlReference { + SqlReference { + call_argument_count: Some(call_argument_count), + ..reference("table_fn", name) + } +} + +pub(crate) fn dbt_reference(package: &str, name: &str) -> SqlReference { + SqlReference { + package: Some(package.to_string()), + ..reference("dbt_ref", name) + } +} + +pub(crate) fn extracted(references: Vec) -> ReferenceExtraction { + ReferenceExtraction::Extracted(references) +} + +pub(crate) fn failed(message: &str) -> ReferenceExtraction { + ReferenceExtraction::Failed(message.to_string()) +} diff --git a/crates/sqlbuild-sqltext/src/sql_references/tests/test_extract_sql_references.rs b/crates/sqlbuild-sqltext/src/sql_references/tests/test_extract_sql_references.rs new file mode 100644 index 0000000000..efe09d2111 --- /dev/null +++ b/crates/sqlbuild-sqltext/src/sql_references/tests/test_extract_sql_references.rs @@ -0,0 +1,188 @@ +use crate::sql_references::main::extract_sql_references::extract_sql_references; +use crate::sql_references::models::ReferenceExtraction; +use crate::sql_references::tests::helpers::{ + backslash_hash_comments, dbt_reference, extracted, failed, generic, reference, table_function, + unsupported_comments, +}; +use crate::sql_references::tests::test_types::ExtractSqlReferencesTestCase; + +#[test] +fn given_sql_when_extracting_references_then_matches_python_scanner() { + let test_cases = [ + ExtractSqlReferencesTestCase { + description: "every kind in authored order", + sql: "SELECT * FROM __ref(orders) JOIN __source('raw_orders') JOIN __seed(\"regions\") \ + JOIN __dbt_ref('shop', customers) WHERE __udf(clean)(x) > 0", + syntax: generic(), + expected_extraction: extracted(vec![ + reference("ref", "orders"), + reference("source", "raw_orders"), + reference("seed", "regions"), + dbt_reference("shop", "customers"), + reference("udf", "clean"), + ]), + }, + ExtractSqlReferencesTestCase { + description: "table function counts top-level call arguments", + sql: "SELECT * FROM __table_fn(\"expand_orders\") ( 1, f(2, 3), 'a,b' /* , */ )", + syntax: generic(), + expected_extraction: extracted(vec![table_function("expand_orders", 3)]), + }, + ExtractSqlReferencesTestCase { + description: "table function without call arguments", + sql: "SELECT * FROM __table_fn(\"daily_orders\")()", + syntax: generic(), + expected_extraction: extracted(vec![table_function("daily_orders", 0)]), + }, + ExtractSqlReferencesTestCase { + description: "references inside table function arguments are scanned", + sql: "SELECT * FROM __table_fn(\"f\")((SELECT id FROM __ref(\"orders\")))", + syntax: generic(), + expected_extraction: extracted(vec![ + table_function("f", 1), + reference("ref", "orders"), + ]), + }, + ExtractSqlReferencesTestCase { + description: "comments become spaces inside quoted names", + sql: "SELECT * FROM __ref(\"a\" /* note */ \"b\")", + syntax: generic(), + expected_extraction: extracted(vec![reference("ref", "a\" \"b")]), + }, + ExtractSqlReferencesTestCase { + description: "comments, strings and dollar quotes hide references", + sql: "-- __ref(a)\nSELECT '__ref(b)', $$__ref(c)$$, /* __ref(d) */ 1 FROM __ref(e)", + syntax: generic(), + expected_extraction: extracted(vec![reference("ref", "e")]), + }, + ExtractSqlReferencesTestCase { + description: "dialect hash comments hide references", + sql: "SELECT 1 # __ref(a)\nFROM __ref(b)", + syntax: backslash_hash_comments(), + expected_extraction: extracted(vec![reference("ref", "b")]), + }, + ExtractSqlReferencesTestCase { + description: "dialect backslash escapes keep strings open", + sql: "SELECT 'it\\'s __ref(a)' FROM __ref(b)", + syntax: backslash_hash_comments(), + expected_extraction: extracted(vec![reference("ref", "b")]), + }, + ExtractSqlReferencesTestCase { + description: "non-ascii quoted names are exact", + sql: "SELECT * FROM __ref(\"commandes_é\")", + syntax: generic(), + expected_extraction: extracted(vec![reference("ref", "commandes_é")]), + }, + ExtractSqlReferencesTestCase { + description: "wrong reference argument count", + sql: "SELECT * FROM __ref(a, b)", + syntax: generic(), + expected_extraction: failed("__ref must contain exactly one name argument"), + }, + ExtractSqlReferencesTestCase { + description: "wrong dbt reference argument count", + sql: "SELECT * FROM __dbt_ref(a, b, c)", + syntax: generic(), + expected_extraction: failed( + "__dbt_ref must contain one name argument or package/name arguments", + ), + }, + ExtractSqlReferencesTestCase { + description: "empty reference argument", + sql: "SELECT * FROM __source(a, )", + syntax: generic(), + expected_extraction: failed("SQL reference contains an empty argument"), + }, + ExtractSqlReferencesTestCase { + description: "empty table function call argument", + sql: "SELECT * FROM __table_fn(\"f\")(1,,2)", + syntax: generic(), + expected_extraction: failed("SQL reference contains an empty argument"), + }, + ExtractSqlReferencesTestCase { + description: "table function without call suffix", + sql: "SELECT * FROM __table_fn(\"f\") /* x */ (1)", + syntax: generic(), + expected_extraction: failed("__table_fn must be followed by an argument list"), + }, + ExtractSqlReferencesTestCase { + description: "table function call suffix checked before the name", + sql: "SELECT * FROM __table_fn(f)", + syntax: generic(), + expected_extraction: failed("__table_fn must be followed by an argument list"), + }, + ExtractSqlReferencesTestCase { + description: "single quoted table function name", + sql: "SELECT * FROM __table_fn('f')(1)", + syntax: generic(), + expected_extraction: failed("__table_fn name argument must be double quoted"), + }, + ExtractSqlReferencesTestCase { + description: "unclosed table function call", + sql: "SELECT * FROM __table_fn(\"f\")(1", + syntax: generic(), + expected_extraction: failed("SQL table function call contains an unclosed parenthesis"), + }, + ExtractSqlReferencesTestCase { + description: "unclosed reference", + sql: "SELECT * FROM __ref(orders", + syntax: generic(), + expected_extraction: failed("SQL reference contains an unclosed parenthesis"), + }, + ExtractSqlReferencesTestCase { + description: "unclosed quoted text", + sql: "SELECT 'orders", + syntax: generic(), + expected_extraction: failed("SQL reference contains an unclosed quoted string"), + }, + ExtractSqlReferencesTestCase { + description: "invalid identifier name", + sql: "SELECT * FROM __ref(1orders)", + syntax: generic(), + expected_extraction: failed( + "__ref name argument must be a quoted string or identifier", + ), + }, + ExtractSqlReferencesTestCase { + description: "nested reference is not a name", + sql: "SELECT * FROM __ref(__ref(a))", + syntax: generic(), + expected_extraction: failed( + "__ref name argument must be a quoted string or identifier", + ), + }, + ExtractSqlReferencesTestCase { + description: "non-ascii identifier defers", + sql: "SELECT * FROM __ref(commandés)", + syntax: generic(), + expected_extraction: ReferenceExtraction::Deferred, + }, + ExtractSqlReferencesTestCase { + description: "non-ascii whitespace before a call suffix defers", + sql: "SELECT * FROM __table_fn(\"f\")\u{a0}(1)", + syntax: generic(), + expected_extraction: ReferenceExtraction::Deferred, + }, + ExtractSqlReferencesTestCase { + description: "python whitespace includes ascii separators", + sql: "SELECT * FROM __table_fn(\"f\")\x1c(1)", + syntax: generic(), + expected_extraction: extracted(vec![table_function("f", 1)]), + }, + ExtractSqlReferencesTestCase { + description: "unsupported line comment prefix defers", + sql: "SELECT * FROM __ref(a)", + syntax: unsupported_comments(), + expected_extraction: ReferenceExtraction::Deferred, + }, + ]; + + for test_case in test_cases { + assert_eq!( + extract_sql_references(test_case.sql, &test_case.syntax), + test_case.expected_extraction, + "{}", + test_case.description + ); + } +} diff --git a/crates/sqlbuild-sqltext/src/sql_references/tests/test_types.rs b/crates/sqlbuild-sqltext/src/sql_references/tests/test_types.rs new file mode 100644 index 0000000000..ad575cf73b --- /dev/null +++ b/crates/sqlbuild-sqltext/src/sql_references/tests/test_types.rs @@ -0,0 +1,9 @@ +use crate::sql_references::models::ReferenceExtraction; +use crate::sql_scan::models::LexicalSyntax; + +pub(crate) struct ExtractSqlReferencesTestCase { + pub(crate) description: &'static str, + pub(crate) sql: &'static str, + pub(crate) syntax: LexicalSyntax, + pub(crate) expected_extraction: ReferenceExtraction, +} diff --git a/crates/sqlbuild-sqltext/src/sql_references/types.rs b/crates/sqlbuild-sqltext/src/sql_references/types.rs new file mode 100644 index 0000000000..b45032984b --- /dev/null +++ b/crates/sqlbuild-sqltext/src/sql_references/types.rs @@ -0,0 +1,4 @@ +//! Shapes shared by the reference scan. + +/// A call prefix such as `__ref(`, its Python reference kind and the name its errors use. +pub(crate) type ReferencePrefix = (&'static [u8], &'static str, &'static str); From cebae72de4a177ade9924d157272b21dc78b8db8 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Wed, 7 Oct 2026 16:10:26 +0100 Subject: [PATCH 2/3] feat(native): extract model references natively behind the preview engine Register reference extraction as a preview-tier native stage. Under native-preview every SQL text goes through the native scan, for every adapter's lexical rules; a deferral runs the Python scanner. A native error stands only when Python raises the same message, which is re-raised from Python; otherwise the compile fails with a NativeStageMismatchError instead of silently falling back. The python and native engines are unchanged. The lexical-syntax mapping for native scanners is shared through SqlLexicalSyntax.native_mapping and one binding input type. --- .../_helpers/scopes/relationship_names.rs | 28 +- .../_helpers/sqltext/lexical_syntax.rs | 29 ++ .../src/bindings/_helpers/sqltext/mod.rs | 3 + .../bindings/_helpers/sqltext/registration.rs | 12 + .../_helpers/sqltext/sql_references.rs | 59 +++ .../src/bindings/main/register.rs | 3 +- src/sqlbuild/_native.pyi | 7 + .../compiler/compile/_helpers/refs/native.py | 42 +++ .../compile/_helpers/refs/references.py | 43 +++ src/sqlbuild/compiler/frontier/constants.py | 1 + src/sqlbuild/compiler/frontier/exceptions.py | 4 + src/sqlbuild/compiler/frontier/types.py | 1 + .../main/_native_expected_model_names.py | 10 +- src/sqlbuild/compiler/sql_analysis/models.py | 13 + .../src/sqlbuild/compiler/compile/__init__.py | 0 .../compiler/compile/_helpers/__init__.py | 0 .../compile/_helpers/refs/__init__.py | 0 .../compile/_helpers/refs/_test_types.py | 60 ++++ .../compiler/compile/_helpers/refs/helpers.py | 219 ++++++++++++ .../_helpers/refs/test_native_references.py | 338 ++++++++++++++++++ .../frontier/test_native_stage_tiers.py | 18 + 21 files changed, 853 insertions(+), 37 deletions(-) create mode 100644 crates/sqlbuild-python/src/bindings/_helpers/sqltext/lexical_syntax.rs create mode 100644 crates/sqlbuild-python/src/bindings/_helpers/sqltext/registration.rs create mode 100644 crates/sqlbuild-python/src/bindings/_helpers/sqltext/sql_references.rs create mode 100644 src/sqlbuild/compiler/compile/_helpers/refs/native.py create mode 100644 tests/integration/src/sqlbuild/compiler/compile/__init__.py create mode 100644 tests/integration/src/sqlbuild/compiler/compile/_helpers/__init__.py create mode 100644 tests/integration/src/sqlbuild/compiler/compile/_helpers/refs/__init__.py create mode 100644 tests/integration/src/sqlbuild/compiler/compile/_helpers/refs/_test_types.py create mode 100644 tests/integration/src/sqlbuild/compiler/compile/_helpers/refs/helpers.py create mode 100644 tests/integration/src/sqlbuild/compiler/compile/_helpers/refs/test_native_references.py diff --git a/crates/sqlbuild-python/src/bindings/_helpers/scopes/relationship_names.rs b/crates/sqlbuild-python/src/bindings/_helpers/scopes/relationship_names.rs index 6f32b4c81f..124a38f37b 100644 --- a/crates/sqlbuild-python/src/bindings/_helpers/scopes/relationship_names.rs +++ b/crates/sqlbuild-python/src/bindings/_helpers/scopes/relationship_names.rs @@ -1,39 +1,15 @@ //! Expected-model relationship names of SQL tests and scenarios, scanned natively. use pyo3::prelude::{Bound, PyModule, PyModuleMethods, PyResult, Python}; -use pyo3::{FromPyObject, pyfunction, wrap_pyfunction}; +use pyo3::{pyfunction, wrap_pyfunction}; use sqlbuild_scopes::relationship_names::main::expected_model_names::expected_model_names; use sqlbuild_scopes::relationship_names::models::ExpectedNames; use sqlbuild_sqltext::sql_scan::models::LexicalSyntax; use crate::bindings::_helpers::boundary::panics::compiler_error; +use crate::bindings::_helpers::sqltext::lexical_syntax::LexicalSyntaxInput; use crate::bindings::types::CompilerDetach; -/// One adapter's `SqlLexicalSyntax`, read from a Python mapping. -#[derive(FromPyObject)] -#[pyo3(from_item_all)] -struct LexicalSyntaxInput { - backslash_escape_quotes: Vec, - escape_string_prefix: bool, - raw_string_prefix: bool, - triple_quoted_strings: bool, - nested_block_comments: bool, - line_comment_prefixes: Vec, -} - -impl From for LexicalSyntax { - fn from(input: LexicalSyntaxInput) -> Self { - Self { - backslash_escape_quotes: input.backslash_escape_quotes, - escape_string_prefix: input.escape_string_prefix, - raw_string_prefix: input.raw_string_prefix, - triple_quoted_strings: input.triple_quoted_strings, - nested_block_comments: input.nested_block_comments, - line_comment_prefixes: input.line_comment_prefixes, - } - } -} - /// Return each body's expected-model names, or `None` where Python must extract them. #[pyfunction] fn scope_expected_model_names( diff --git a/crates/sqlbuild-python/src/bindings/_helpers/sqltext/lexical_syntax.rs b/crates/sqlbuild-python/src/bindings/_helpers/sqltext/lexical_syntax.rs new file mode 100644 index 0000000000..60dabcfe4b --- /dev/null +++ b/crates/sqlbuild-python/src/bindings/_helpers/sqltext/lexical_syntax.rs @@ -0,0 +1,29 @@ +//! One adapter's `SqlLexicalSyntax`, read from a Python mapping. + +use pyo3::FromPyObject; +use sqlbuild_sqltext::sql_scan::models::LexicalSyntax; + +/// One adapter's `SqlLexicalSyntax`, read from a Python mapping. +#[derive(FromPyObject)] +#[pyo3(from_item_all)] +pub(crate) struct LexicalSyntaxInput { + backslash_escape_quotes: Vec, + escape_string_prefix: bool, + raw_string_prefix: bool, + triple_quoted_strings: bool, + nested_block_comments: bool, + line_comment_prefixes: Vec, +} + +impl From for LexicalSyntax { + fn from(input: LexicalSyntaxInput) -> Self { + Self { + backslash_escape_quotes: input.backslash_escape_quotes, + escape_string_prefix: input.escape_string_prefix, + raw_string_prefix: input.raw_string_prefix, + triple_quoted_strings: input.triple_quoted_strings, + nested_block_comments: input.nested_block_comments, + line_comment_prefixes: input.line_comment_prefixes, + } + } +} diff --git a/crates/sqlbuild-python/src/bindings/_helpers/sqltext/mod.rs b/crates/sqlbuild-python/src/bindings/_helpers/sqltext/mod.rs index b02161ba3e..2f5ab47c7e 100644 --- a/crates/sqlbuild-python/src/bindings/_helpers/sqltext/mod.rs +++ b/crates/sqlbuild-python/src/bindings/_helpers/sqltext/mod.rs @@ -1,3 +1,6 @@ pub(crate) mod authored_values; +pub(crate) mod lexical_syntax; pub(crate) mod model_headers; +pub(crate) mod registration; +pub(crate) mod sql_references; pub(crate) mod static_sql; diff --git a/crates/sqlbuild-python/src/bindings/_helpers/sqltext/registration.rs b/crates/sqlbuild-python/src/bindings/_helpers/sqltext/registration.rs new file mode 100644 index 0000000000..8182effbbf --- /dev/null +++ b/crates/sqlbuild-python/src/bindings/_helpers/sqltext/registration.rs @@ -0,0 +1,12 @@ +//! Register the lexical SQL text functions and classes. + +use pyo3::prelude::{Bound, PyModule, PyResult}; + +use crate::bindings::_helpers::sqltext::{model_headers, sql_references, static_sql}; + +pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { + model_headers::register(module)?; + static_sql::register(module)?; + sql_references::register(module)?; + Ok(()) +} diff --git a/crates/sqlbuild-python/src/bindings/_helpers/sqltext/sql_references.rs b/crates/sqlbuild-python/src/bindings/_helpers/sqltext/sql_references.rs new file mode 100644 index 0000000000..f36013ea39 --- /dev/null +++ b/crates/sqlbuild-python/src/bindings/_helpers/sqltext/sql_references.rs @@ -0,0 +1,59 @@ +//! Logical SQL references extracted natively under one adapter's lexical rules. + +use pyo3::prelude::{Bound, PyModule, PyModuleMethods, PyResult}; +use pyo3::{pyclass, pymethods}; +use sqlbuild_sqltext::sql_references::main::extract_sql_references::extract_sql_references; +use sqlbuild_sqltext::sql_references::models::{ReferenceExtraction, SqlReference}; +use sqlbuild_sqltext::sql_scan::models::LexicalSyntax; + +use crate::bindings::_helpers::boundary::panics::compiler_guard; +use crate::bindings::_helpers::sqltext::lexical_syntax::LexicalSyntaxInput; + +/// `(kind, name, package, call_argument_count)` for one reference. +type ReferenceRow = (&'static str, String, Option, Option); +/// The references, or Python's error message; `None` defers the text to Python. +type ExtractionRow = Option<(Option>, Option)>; + +/// Extracts references under one adapter's lexical rules, read once per syntax. +#[pyclass(module = "sqlbuild._native", frozen)] +pub(crate) struct SqlReferenceScanner { + syntax: LexicalSyntax, +} + +#[pymethods] +impl SqlReferenceScanner { + #[new] + fn new(syntax: LexicalSyntaxInput) -> Self { + Self { + syntax: syntax.into(), + } + } + + /// Return `(references, None)`, `(None, message)` when Python raises, or `None` to defer. + fn extract(&self, sql: &str) -> PyResult { + compiler_guard(|| { + Ok(match extract_sql_references(sql, &self.syntax) { + ReferenceExtraction::Extracted(references) => Some(( + Some(references.into_iter().map(reference_row).collect()), + None, + )), + ReferenceExtraction::Failed(message) => Some((None, Some(message))), + ReferenceExtraction::Deferred => None, + }) + }) + } +} + +fn reference_row(reference: SqlReference) -> ReferenceRow { + ( + reference.kind, + reference.name, + reference.package, + reference.call_argument_count, + ) +} + +pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { + module.add_class::()?; + Ok(()) +} diff --git a/crates/sqlbuild-python/src/bindings/main/register.rs b/crates/sqlbuild-python/src/bindings/main/register.rs index 6b88d87354..ea826ad8b6 100644 --- a/crates/sqlbuild-python/src/bindings/main/register.rs +++ b/crates/sqlbuild-python/src/bindings/main/register.rs @@ -24,8 +24,7 @@ pub fn _native(module: &Bound<'_, PyModule>) -> PyResult<()> { analysis::normalization::register(module)?; analysis::queries::register(module)?; analysis::sql_tests::register(module)?; - sqltext::model_headers::register(module)?; - sqltext::static_sql::register(module)?; + sqltext::registration::register(module)?; boundary::oracles::register(module)?; discovery::project_files::register(module)?; scopes::scope_index::register(module)?; diff --git a/src/sqlbuild/_native.pyi b/src/sqlbuild/_native.pyi index 9cf76c576e..74ff9791f6 100644 --- a/src/sqlbuild/_native.pyi +++ b/src/sqlbuild/_native.pyi @@ -137,6 +137,13 @@ def expand_config_templates( sources: tuple[dict[str, object], object, dict[str, str | None]], flags: tuple[bool, bool, bool], ) -> tuple[object, list[tuple[str, str]]] | str: ... + +class SqlReferenceScanner: + def __init__(self, syntax: dict[str, object]) -> None: ... + def extract( + self, sql: str + ) -> tuple[list[tuple[str, str, str | None, int | None]], None] | tuple[None, str] | None: ... + def scope_expected_model_names( sqls: list[str], syntax: dict[str, object] ) -> list[list[str] | None]: ... diff --git a/src/sqlbuild/compiler/compile/_helpers/refs/native.py b/src/sqlbuild/compiler/compile/_helpers/refs/native.py new file mode 100644 index 0000000000..ad62a54a17 --- /dev/null +++ b/src/sqlbuild/compiler/compile/_helpers/refs/native.py @@ -0,0 +1,42 @@ +"""Logical SQL references extracted by the native scanner.""" + +from __future__ import annotations + +from functools import cache + +import sqlbuild._native as _native +from sqlbuild.compiler.compile.models import CompileSqlReference +from sqlbuild.compiler.references.types import SqlReferenceKind +from sqlbuild.compiler.sql_analysis.models import SqlLexicalSyntax + + +def extract_native_sql_references( + *, sql: str, syntax: SqlLexicalSyntax +) -> tuple[CompileSqlReference, ...] | str | None: + """Return the references, the message Python raises, or None where Python must extract.""" + + try: + extraction: ( + tuple[list[tuple[str, str, str | None, int | None]], None] | tuple[None, str] | None + ) = _scanner(syntax).extract(sql) + except UnicodeError: + return None + if extraction is None: + return None + references, message = extraction + if references is None: + return message + return tuple( + CompileSqlReference( + ref_kind=SqlReferenceKind(kind), + ref_name=name, + ref_package=package, + call_argument_count=call_argument_count, + ) + for kind, name, package, call_argument_count in references + ) + + +@cache +def _scanner(syntax: SqlLexicalSyntax) -> _native.SqlReferenceScanner: + return _native.SqlReferenceScanner(syntax.native_mapping) diff --git a/src/sqlbuild/compiler/compile/_helpers/refs/references.py b/src/sqlbuild/compiler/compile/_helpers/refs/references.py index 9671450f92..782d015192 100644 --- a/src/sqlbuild/compiler/compile/_helpers/refs/references.py +++ b/src/sqlbuild/compiler/compile/_helpers/refs/references.py @@ -5,6 +5,7 @@ import re import sqlbuild._native as _native +from sqlbuild.compiler.compile._helpers.refs.native import extract_native_sql_references from sqlbuild.compiler.compile.constants import ( SQL_ARGUMENT_SEPARATOR_TOKEN, SQL_CLOSE_PAREN_TOKEN, @@ -14,6 +15,10 @@ ) from sqlbuild.compiler.compile.exceptions import CompileInputError from sqlbuild.compiler.compile.models import CompileSqlReference +from sqlbuild.compiler.frontier.constants import COMPILER_ENGINE_ENV_VAR +from sqlbuild.compiler.frontier.exceptions import NativeStageMismatchError +from sqlbuild.compiler.frontier.main.native_stage_enabled import native_stage_enabled +from sqlbuild.compiler.frontier.types import CompilerEngine, NativeStage from sqlbuild.compiler.references.types import SqlReferenceKind from sqlbuild.compiler.sql_analysis.main._find_matching_paren import find_matching_paren from sqlbuild.compiler.sql_analysis.main._skip_dialect_non_code import dialect_non_code_end @@ -41,6 +46,8 @@ def extract_sql_references( ) -> tuple[CompileSqlReference, ...]: """Return logical SQL refs found outside comments and quoted text under the dialect's rules.""" + if native_stage_enabled(NativeStage.REFERENCE_EXTRACTION): + return _extract_sql_references_natively(sql=sql, syntax=syntax) if syntax.reads_differently_from_generic(sql): return _extract_sql_references_with_python(sql=sql, syntax=syntax) native_references: list[tuple[str, str, str | None, int | None]] | None = ( @@ -59,6 +66,42 @@ def extract_sql_references( return _extract_sql_references_with_python(sql=sql, syntax=syntax) +def _extract_sql_references_natively( + *, sql: str, syntax: SqlLexicalSyntax +) -> tuple[CompileSqlReference, ...]: + """Return the native refs; a native error stands only when Python raises the same error.""" + + native_references: tuple[CompileSqlReference, ...] | str | None = extract_native_sql_references( + sql=sql, syntax=syntax + ) + if native_references is None: + return _extract_sql_references_with_python(sql=sql, syntax=syntax) + if not isinstance(native_references, str): + return native_references + try: + python_references: tuple[CompileSqlReference, ...] = _extract_sql_references_with_python( + sql=sql, syntax=syntax + ) + except CompileInputError as error: + if str(error) == native_references: + raise + raise _reference_mismatch( + native_message=native_references, python_outcome=f"raised {str(error)!r}" + ) from error + raise _reference_mismatch( + native_message=native_references, + python_outcome=f"found {len(python_references)} reference(s)", + ) + + +def _reference_mismatch(*, native_message: str, python_outcome: str) -> NativeStageMismatchError: + return NativeStageMismatchError( + f"native {NativeStage.REFERENCE_EXTRACTION} raised {native_message!r} but the Python " + f"compiler {python_outcome}; rerun with {COMPILER_ENGINE_ENV_VAR}=" + f"{CompilerEngine.PYTHON} and report this mismatch" + ) + + def _extract_sql_references_with_python( *, sql: str, syntax: SqlLexicalSyntax ) -> tuple[CompileSqlReference, ...]: diff --git a/src/sqlbuild/compiler/frontier/constants.py b/src/sqlbuild/compiler/frontier/constants.py index d0e5cd5007..b974d7146c 100644 --- a/src/sqlbuild/compiler/frontier/constants.py +++ b/src/sqlbuild/compiler/frontier/constants.py @@ -19,6 +19,7 @@ NativeStage.DISCOVERY: NativeStageTier.SHIPPED, NativeStage.DECLARATION_SCOPES: NativeStageTier.PREVIEW, NativeStage.MODEL_CONFIG: NativeStageTier.PREVIEW, + NativeStage.REFERENCE_EXTRACTION: NativeStageTier.PREVIEW, } ENGINE_NATIVE_STAGE_TIERS: dict[CompilerEngine, frozenset[NativeStageTier]] = { CompilerEngine.PYTHON: frozenset(), diff --git a/src/sqlbuild/compiler/frontier/exceptions.py b/src/sqlbuild/compiler/frontier/exceptions.py index 81f0c64dc3..4fa3b39ef2 100644 --- a/src/sqlbuild/compiler/frontier/exceptions.py +++ b/src/sqlbuild/compiler/frontier/exceptions.py @@ -3,3 +3,7 @@ class CompilerEngineError(ValueError): """Raised when the requested compiler engine is not a supported value.""" + + +class NativeStageMismatchError(RuntimeError): + """Raised when a native stage and the Python compiler disagree about one input.""" diff --git a/src/sqlbuild/compiler/frontier/types.py b/src/sqlbuild/compiler/frontier/types.py index 2acb0641f6..56c5ac01cb 100644 --- a/src/sqlbuild/compiler/frontier/types.py +++ b/src/sqlbuild/compiler/frontier/types.py @@ -17,6 +17,7 @@ class NativeStage(StrEnum): DISCOVERY = "discovery" DECLARATION_SCOPES = "declaration_scopes" MODEL_CONFIG = "model_config" + REFERENCE_EXTRACTION = "reference_extraction" class NativeStageTier(StrEnum): diff --git a/src/sqlbuild/compiler/scopes/main/_native_expected_model_names.py b/src/sqlbuild/compiler/scopes/main/_native_expected_model_names.py index 0f37b7504f..ecc08957b8 100644 --- a/src/sqlbuild/compiler/scopes/main/_native_expected_model_names.py +++ b/src/sqlbuild/compiler/scopes/main/_native_expected_model_names.py @@ -17,15 +17,7 @@ def native_expected_model_names( return [] try: names: list[list[str] | None] = _native.scope_expected_model_names( - list(sqls), - { - "backslash_escape_quotes": sorted(syntax.backslash_escape_quotes), - "escape_string_prefix": syntax.escape_string_prefix, - "raw_string_prefix": syntax.raw_string_prefix, - "triple_quoted_strings": syntax.triple_quoted_strings, - "nested_block_comments": syntax.nested_block_comments, - "line_comment_prefixes": sorted(syntax.line_comment_prefixes), - }, + list(sqls), syntax.native_mapping ) except (TypeError, UnicodeError): return [None] * len(sqls) diff --git a/src/sqlbuild/compiler/sql_analysis/models.py b/src/sqlbuild/compiler/sql_analysis/models.py index 7ddbd859e5..e002f357eb 100644 --- a/src/sqlbuild/compiler/sql_analysis/models.py +++ b/src/sqlbuild/compiler/sql_analysis/models.py @@ -74,6 +74,19 @@ def cache_key(self) -> str: ) ) + @property + def native_mapping(self) -> dict[str, object]: + """Return these lexical rules as the mapping native scanners read.""" + + return { + "backslash_escape_quotes": sorted(self.backslash_escape_quotes), + "escape_string_prefix": self.escape_string_prefix, + "raw_string_prefix": self.raw_string_prefix, + "triple_quoted_strings": self.triple_quoted_strings, + "nested_block_comments": self.nested_block_comments, + "line_comment_prefixes": sorted(self.line_comment_prefixes), + } + def reads_differently_from_generic(self, sql: str) -> bool: """Return whether these rules can read the SQL differently from generic SQL.""" diff --git a/tests/integration/src/sqlbuild/compiler/compile/__init__.py b/tests/integration/src/sqlbuild/compiler/compile/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/integration/src/sqlbuild/compiler/compile/_helpers/__init__.py b/tests/integration/src/sqlbuild/compiler/compile/_helpers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/integration/src/sqlbuild/compiler/compile/_helpers/refs/__init__.py b/tests/integration/src/sqlbuild/compiler/compile/_helpers/refs/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/integration/src/sqlbuild/compiler/compile/_helpers/refs/_test_types.py b/tests/integration/src/sqlbuild/compiler/compile/_helpers/refs/_test_types.py new file mode 100644 index 0000000000..5c07c1fd34 --- /dev/null +++ b/tests/integration/src/sqlbuild/compiler/compile/_helpers/refs/_test_types.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from sqlbuild.compiler.frontier.types import CompilerEngine + + +@dataclass(frozen=True) +class CraftedReferenceParityTestCase: + """One SQL text whose native extraction must equal Python's under every lexical syntax.""" + + description: str + sql: str + expected_deferred: bool = False + + +@dataclass(frozen=True) +class GeneratedReferenceParityTestCase: + """Seeded reference SQL compared with Python, with minimum coverage of each outcome.""" + + description: str + syntax: str + seed: int + count: int + expected_minimum_extracted: int + expected_minimum_failed: int + expected_minimum_table_functions: int + expected_maximum_deferred: int + + +@dataclass(frozen=True) +class ReferenceEngineTestCase: + """Reference extraction through the compiler entry point under one engine.""" + + description: str + engine: CompilerEngine + sql: str + expected_references: tuple[tuple[str, str, str | None, int | None], ...] = () + expected_error: str = "" + expected_native_calls: int = 0 + + +@dataclass(frozen=True) +class ReferenceMismatchTestCase: + """A native error that Python does not raise identically.""" + + description: str + sql: str + native_message: str + expected_python_outcome: str + + +@dataclass(frozen=True) +class ReferenceScanBoundTestCase: + """A worst-case SQL shape the native scan must finish within a strict time limit.""" + + description: str + sql: str + expected_outcome: int | str + expected_maximum_seconds: float diff --git a/tests/integration/src/sqlbuild/compiler/compile/_helpers/refs/helpers.py b/tests/integration/src/sqlbuild/compiler/compile/_helpers/refs/helpers.py new file mode 100644 index 0000000000..b17ec2488a --- /dev/null +++ b/tests/integration/src/sqlbuild/compiler/compile/_helpers/refs/helpers.py @@ -0,0 +1,219 @@ +"""Generated reference SQL and native-versus-Python comparison for reference extraction.""" + +from __future__ import annotations + +import random +from dataclasses import dataclass +from itertools import chain, compress +from typing import cast + +from sqlbuild.adapters.bigquery.classes.bigquery_adapter import BigQueryAdapter +from sqlbuild.adapters.databricks.classes.databricks_adapter import DatabricksAdapter +from sqlbuild.adapters.duckdb.classes.duckdb_adapter import DuckDbAdapter +from sqlbuild.adapters.postgres.classes.postgres_adapter import PostgresAdapter +from sqlbuild.adapters.snowflake.classes.snowflake_adapter import SnowflakeAdapter +from sqlbuild.adapters.sqlserver.classes.sqlserver_adapter import SqlServerAdapter +from sqlbuild.compiler.compile._helpers.refs.native import extract_native_sql_references +from sqlbuild.compiler.compile._helpers.refs.references import ( + _extract_sql_references_with_python, +) +from sqlbuild.compiler.compile.exceptions import CompileInputError +from sqlbuild.compiler.compile.models import CompileSqlReference +from sqlbuild.compiler.references.types import SqlReferenceKind +from sqlbuild.compiler.sql_analysis.models import SqlLexicalSyntax +from tests.integration.src.sqlbuild.compiler.helpers import mismatches + +LEXICAL_SYNTAXES: dict[str, SqlLexicalSyntax] = { + "generic": SqlLexicalSyntax(), + "bigquery": BigQueryAdapter.sql_lexical_syntax, + "databricks": DatabricksAdapter.sql_lexical_syntax, + "duckdb": DuckDbAdapter.sql_lexical_syntax, + "postgres": PostgresAdapter.sql_lexical_syntax, + "snowflake": SnowflakeAdapter.sql_lexical_syntax, + "sqlserver": SqlServerAdapter.sql_lexical_syntax, +} +_NAMES: tuple[str, ...] = ( + "'customers'", + "products", + " inventory\t", + "'commandés'", + "orders\x1c", + "orders /* note */", + "-- note\norders", +) +_QUOTED_NAMES: tuple[str, ...] = ( + '"orders"', + ' "order""s" ', + '"a" "b"', + '""', + '"größe"', + '/* note */ "orders"', +) +_MALFORMED_NAMES: tuple[str, ...] = ( + "'x\"", + "1orders", + "_orders", + "order-items", + "commandés", + "\u00a0orders", + "concat('a', 'b')", + '__ref("orders")', + "", + " ", + "/* only */", + "'orders'", + "orders", +) +_NO_SECOND_ARGUMENT: tuple[str, ...] = ("",) +_DBT_SECOND_ARGUMENTS: tuple[str, ...] = ("", ", products", ' , "orders"') +_MALFORMED_SECOND_ARGUMENTS: tuple[str, ...] = (",", ", ", ",, products", ", products", ' ,"a"') +_NO_CALL_SUFFIX: tuple[str, ...] = ("",) +_CALL_SUFFIXES: tuple[str, ...] = ( + "()", + "(1)", + " (1, 2)", + "\n(f(1, 2), 'a,b')", + "((SELECT 1), $$a,b$$)", + '(__ref("orders"))', + "(1 /* , */, 2 -- ,\n)", + "\x1c(1)", +) +_MALFORMED_CALL_SUFFIXES: tuple[str, ...] = ( + "(1,)", + "(, 1)", + "/* gap */ (1)", + "\u00a0(1)", + "", + "(1", + " x", +) +_FILLERS: tuple[str, ...] = ( + "SELECT * FROM ", + " JOIN ", + "\n", + "'__ref(\"hidden\")'", + '"quoted__ref(x)"', + "`tick__ref(x)`", + "$$ __ref(hidden) $$", + "$tag$ __table_fn(hidden) $tag$", + "-- __ref(hidden)\n", + "/* __ref(hidden) */", + "'it''s'", + "a$$b", + "é", +) +_RISKY_FILLERS: tuple[str, ...] = ( + "# __ref(hidden)\n", + "// __ref(hidden)\n", + "/* outer /* inner */ __ref(nested) */", + "'it\\'s __ref(escaped)'", + "E'it\\'s __ref(escaped)'", + "r'C:\\' __ref(raw)", + "'''it's __ref(triple)'''", + "(", + ")", + "'unclosed", + "/* unclosed", + "$$unclosed", +) +_USUAL_AND_RARE_WEIGHTS: tuple[int, int] = (94, 6) + + +@dataclass(frozen=True) +class _CallShape: + call: str + names: tuple[str, ...] + second_arguments: tuple[str, ...] + call_suffixes: tuple[str, ...] + + +_ANY_NAMES: tuple[str, ...] = (*_NAMES, *_QUOTED_NAMES) +_CALL_SHAPES: tuple[_CallShape, ...] = ( + _CallShape("__ref(", _ANY_NAMES, _NO_SECOND_ARGUMENT, _NO_CALL_SUFFIX), + _CallShape("__source(", _ANY_NAMES, _NO_SECOND_ARGUMENT, _NO_CALL_SUFFIX), + _CallShape("__seed(", _ANY_NAMES, _NO_SECOND_ARGUMENT, _NO_CALL_SUFFIX), + _CallShape("__udf(", _ANY_NAMES, _NO_SECOND_ARGUMENT, ("(x)",)), + _CallShape("__dbt_ref(", _ANY_NAMES, _DBT_SECOND_ARGUMENTS, _NO_CALL_SUFFIX), + _CallShape("__table_fn(", _QUOTED_NAMES, _NO_SECOND_ARGUMENT, _CALL_SUFFIXES), + _CallShape("__table_fn(", _QUOTED_NAMES, _NO_SECOND_ARGUMENT, _CALL_SUFFIXES), + _CallShape("___ref(", _ANY_NAMES, _NO_SECOND_ARGUMENT, _NO_CALL_SUFFIX), + _CallShape("x__ref(", _ANY_NAMES, _NO_SECOND_ARGUMENT, _NO_CALL_SUFFIX), +) + + +def _pick(*, rng: random.Random, usual: tuple[str, ...], rare: tuple[str, ...]) -> str: + return rng.choice(rng.choices((usual, rare), weights=_USUAL_AND_RARE_WEIGHTS)[0]) + + +@dataclass(frozen=True) +class ReferenceParity: + """How native extraction compared with Python over one corpus.""" + + mismatches: list[tuple[object, object, object]] + extracted: int + failed: int + deferred: int + table_functions: int + + +def generated_reference_sqls(*, rng: random.Random, count: int) -> list[str]: + """Return seeded SQL mixing valid, malformed, hidden and dialect-sensitive reference calls.""" + + return [_generated_reference_sql(rng=rng) for _ in range(count)] + + +def _generated_reference_sql(*, rng: random.Random) -> str: + return "".join(_generated_reference_call(rng=rng) for _ in range(rng.randint(1, 4))) + + +def _generated_reference_call(*, rng: random.Random) -> str: + shape: _CallShape = rng.choice(_CALL_SHAPES) + return "".join( + ( + _pick(rng=rng, usual=_FILLERS, rare=_RISKY_FILLERS), + shape.call, + _pick(rng=rng, usual=shape.names, rare=_MALFORMED_NAMES), + _pick(rng=rng, usual=shape.second_arguments, rare=_MALFORMED_SECOND_ARGUMENTS), + ")", + _pick(rng=rng, usual=shape.call_suffixes, rare=_MALFORMED_CALL_SUFFIXES), + ) + ) + + +def python_outcome(*, sql: str, syntax: SqlLexicalSyntax) -> tuple[CompileSqlReference, ...] | str: + """Return the oracle scanner's references or the message of the error it raises.""" + + try: + return _extract_sql_references_with_python(sql=sql, syntax=syntax) + except CompileInputError as error: + return str(error) + + +def reference_parity(*, sqls: list[str], syntax: SqlLexicalSyntax) -> ReferenceParity: + """Compare native extraction with Python wherever the native scan does not defer.""" + + native: list[tuple[CompileSqlReference, ...] | str | None] = [ + extract_native_sql_references(sql=sql, syntax=syntax) for sql in sqls + ] + python: list[tuple[CompileSqlReference, ...] | str] = [ + python_outcome(sql=sql, syntax=syntax) for sql in sqls + ] + scanned: list[bool] = [outcome is not None for outcome in native] + extracted: list[tuple[CompileSqlReference, ...]] = cast( + list[tuple[CompileSqlReference, ...]], + list(compress(native, [isinstance(outcome, tuple) for outcome in native])), + ) + return ReferenceParity( + mismatches=mismatches( + inputs=list(compress(sqls, scanned)), + expected=list(compress(python, scanned)), + actual=list(compress(native, scanned)), + ), + extracted=len(extracted), + failed=sum(isinstance(outcome, str) for outcome in native), + deferred=scanned.count(False), + table_functions=sum( + reference.ref_kind is SqlReferenceKind.TABLE_FUNCTION + for reference in chain.from_iterable(extracted) + ), + ) diff --git a/tests/integration/src/sqlbuild/compiler/compile/_helpers/refs/test_native_references.py b/tests/integration/src/sqlbuild/compiler/compile/_helpers/refs/test_native_references.py new file mode 100644 index 0000000000..76ae127c5e --- /dev/null +++ b/tests/integration/src/sqlbuild/compiler/compile/_helpers/refs/test_native_references.py @@ -0,0 +1,338 @@ +"""Native reference extraction returns Python's references or error exactly, or defers to Python.""" + +from __future__ import annotations + +import random +import time +from collections.abc import Callable +from itertools import product +from typing import Any + +import pytest + +from sqlbuild.compiler.compile._helpers.refs import references +from sqlbuild.compiler.compile._helpers.refs.native import extract_native_sql_references +from sqlbuild.compiler.compile._helpers.refs.references import extract_sql_references +from sqlbuild.compiler.compile.exceptions import CompileInputError +from sqlbuild.compiler.compile.models import CompileSqlReference +from sqlbuild.compiler.frontier.constants import COMPILER_ENGINE_ENV_VAR +from sqlbuild.compiler.frontier.exceptions import NativeStageMismatchError +from sqlbuild.compiler.frontier.types import CompilerEngine +from sqlbuild.compiler.sql_analysis.models import SqlLexicalSyntax +from tests.integration.src.sqlbuild.compiler.compile._helpers.refs._test_types import ( + CraftedReferenceParityTestCase, + GeneratedReferenceParityTestCase, + ReferenceEngineTestCase, + ReferenceMismatchTestCase, + ReferenceScanBoundTestCase, +) +from tests.integration.src.sqlbuild.compiler.compile._helpers.refs.helpers import ( + LEXICAL_SYNTAXES, + ReferenceParity, + generated_reference_sqls, + reference_parity, +) + +_GENERIC_SYNTAX: SqlLexicalSyntax = LEXICAL_SYNTAXES["generic"] +_ENGINE_SQLS: tuple[ + tuple[str, str, tuple[tuple[str, str, str | None, int | None], ...], str], ... +] = ( + ( + "table_function", + 'SELECT * FROM __table_fn("orders_for")(1, 2) JOIN __ref(customers)', + (("table_fn", "orders_for", None, 2), ("ref", "customers", None, None)), + "", + ), + ( + "argument_count_error", + "SELECT * FROM __ref(orders, customers)", + (), + "__ref must contain exactly one name argument", + ), +) + +_MANY_CALLS: str = "SELECT 1 FROM " + " JOIN ".join( + f"__table_fn(\"orders_for\")({index}, 'a,b', (1, 2)) JOIN __ref(orders_{index})" + for index in range(5_000) +) +_WIDE_CALL: str = ( + '__table_fn("orders_for")(' + ", ".join(f"(c{index} /* , */)" for index in range(100_000)) + ")" +) +_NESTED_CALLS: str = '__table_fn("orders_for")(' * 1_000 + "1" + ")" * 1_000 +_OUTCOME_SUMMARIES: dict[type, Callable[[Any], int | str]] = {tuple: len, str: str} +_ERROR_AFTER_MANY: str = ( + "SELECT 1 FROM " + + " JOIN ".join(f"__ref(orders_{index})" for index in range(50_000)) + + " JOIN __ref(orders, customers)" +) + + +@pytest.mark.parametrize( + "test_case", + [ + CraftedReferenceParityTestCase( + description="every_kind_in_authored_order", + sql=( + "SELECT * FROM __ref(orders) JOIN __source('raw_orders') " + "JOIN __seed(\"regions\") JOIN __dbt_ref('shop', customers) " + "WHERE __udf(clean)(x) > 0" + ), + ), + CraftedReferenceParityTestCase( + description="table_function_argument_counts", + sql=( + "SELECT * FROM __table_fn(\"expand_orders\") ( 1, f(2, 3), 'a,b' /* , */ ) " + 'JOIN __table_fn("daily_orders")() JOIN __table_fn("by_day")\n(1 -- ,\n, 2)' + ), + ), + CraftedReferenceParityTestCase( + description="references_nested_in_table_function_arguments", + sql='SELECT * FROM __table_fn("orders_for")((SELECT id FROM __ref("customers")))', + ), + CraftedReferenceParityTestCase( + description="hidden_in_comments_strings_and_dollar_quotes", + sql=( + "-- __ref(a)\nSELECT '__ref(b)', $$__table_fn(\"c\")$$, $t$ __ref(d) $t$, " + '/* __ref(e) */ "__ref(f)", `__ref(g)` FROM __ref(h)' + ), + ), + CraftedReferenceParityTestCase( + description="dialect_sensitive_text", + sql=( + "SELECT 'it\\'s __ref(a)', E'x\\'__ref(b)', r'C:\\' AS p, '''__ref(c)''', " + "/* x /* y */ __ref(d) */ 1 # __ref(e)\n// __ref(f)\nFROM __ref(g)" + ), + ), + CraftedReferenceParityTestCase( + description="comments_inside_quoted_names", + sql='SELECT * FROM __ref("a" /* note */ "b") JOIN __ref(-- c\n"orders")', + ), + CraftedReferenceParityTestCase( + description="non_ascii_quoted_names_and_text", + sql="SELECT 'é' FROM __ref(\"commandés\") JOIN __table_fn(\"größe\")('ü')", + ), + CraftedReferenceParityTestCase( + description="wrong_reference_argument_count", + sql="SELECT * FROM __ref(orders, customers)", + ), + CraftedReferenceParityTestCase( + description="wrong_dbt_reference_argument_count", + sql="SELECT * FROM __dbt_ref(shop, orders, extra)", + ), + CraftedReferenceParityTestCase( + description="empty_table_function_call_argument", + sql='SELECT * FROM __table_fn("orders_for")(1,,2)', + ), + CraftedReferenceParityTestCase( + description="table_function_without_call", + sql='SELECT * FROM __table_fn("orders_for") /* gap */ (1)', + ), + CraftedReferenceParityTestCase( + description="table_function_name_checked_after_call", + sql="SELECT * FROM __table_fn(orders_for)(1)", + ), + CraftedReferenceParityTestCase( + description="unclosed_table_function_call", + sql='SELECT * FROM __table_fn("orders_for")(1', + ), + CraftedReferenceParityTestCase( + description="nested_reference_as_name", + sql="SELECT * FROM __ref(__ref(orders))", + ), + CraftedReferenceParityTestCase( + description="non_ascii_identifier_defers", + sql="SELECT * FROM __ref(commandés)", + expected_deferred=True, + ), + CraftedReferenceParityTestCase( + description="unicode_whitespace_before_call_defers", + sql='SELECT * FROM __table_fn("orders_for")\u00a0(1)', + expected_deferred=True, + ), + ], + ids=lambda case: case.description, +) +def test_given_crafted_reference_sql_when_extracting_natively_then_matches_python_or_defers( + test_case: CraftedReferenceParityTestCase, +) -> None: + parity: ReferenceParity = reference_parity(sqls=[test_case.sql], syntax=_GENERIC_SYNTAX) + dialect_mismatches: list[list[tuple[object, object, object]]] = [ + reference_parity(sqls=[test_case.sql], syntax=syntax).mismatches + for syntax in LEXICAL_SYNTAXES.values() + ] + + assert dialect_mismatches == [[]] * len(LEXICAL_SYNTAXES) + assert parity.deferred == int(test_case.expected_deferred) + + +@pytest.mark.parametrize( + "test_case", + [ + GeneratedReferenceParityTestCase( + description=f"{syntax}_syntax", + syntax=syntax, + seed=20261007 + offset, + count=3000, + expected_minimum_extracted=1500, + expected_minimum_failed=500, + expected_minimum_table_functions=500, + expected_maximum_deferred=200, + ) + for offset, syntax in enumerate(LEXICAL_SYNTAXES) + ], + ids=lambda case: case.description, +) +def test_given_generated_reference_sql_when_extracting_natively_then_matches_python_or_defers( + test_case: GeneratedReferenceParityTestCase, +) -> None: + sqls: list[str] = generated_reference_sqls( + rng=random.Random(test_case.seed), count=test_case.count + ) + + parity: ReferenceParity = reference_parity(sqls=sqls, syntax=LEXICAL_SYNTAXES[test_case.syntax]) + + assert ( + parity.mismatches, + parity.extracted >= test_case.expected_minimum_extracted, + parity.failed >= test_case.expected_minimum_failed, + parity.table_functions >= test_case.expected_minimum_table_functions, + parity.deferred <= test_case.expected_maximum_deferred, + ) == ([], True, True, True, True), (parity.extracted, parity.failed, parity.deferred) + + +@pytest.mark.parametrize( + "test_case", + [ + ReferenceEngineTestCase( + description=f"{engine}_{name}", + engine=engine, + sql=sql, + expected_references=expected_references, + expected_error=expected_error, + expected_native_calls=int(engine is CompilerEngine.NATIVE_PREVIEW), + ) + for engine, (name, sql, expected_references, expected_error) in product( + CompilerEngine, _ENGINE_SQLS + ) + ], + ids=lambda case: case.description, +) +def test_given_engine_when_extracting_references_then_only_preview_runs_native_scanner( + test_case: ReferenceEngineTestCase, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv(COMPILER_ENGINE_ENV_VAR, test_case.engine.value) + native_calls: list[str] = [] + native_extract: Callable[..., tuple[CompileSqlReference, ...] | str | None] = ( + references.extract_native_sql_references + ) + + def counting_extract( + *, sql: str, syntax: SqlLexicalSyntax + ) -> tuple[CompileSqlReference, ...] | str | None: + native_calls.append(sql) + return native_extract(sql=sql, syntax=syntax) + + monkeypatch.setattr(references, "extract_native_sql_references", counting_extract) + + error: str = "" + extracted: tuple[tuple[str, str, str | None, int | None], ...] = () + try: + extracted = tuple( + (str(ref.ref_kind), ref.ref_name, ref.ref_package, ref.call_argument_count) + for ref in extract_sql_references(sql=test_case.sql, syntax=_GENERIC_SYNTAX) + ) + except CompileInputError as raised: + error = str(raised) + + assert (extracted, error, len(native_calls)) == ( + test_case.expected_references, + test_case.expected_error, + test_case.expected_native_calls, + ) + + +@pytest.mark.parametrize( + "test_case", + [ + ReferenceMismatchTestCase( + description="python_extracts_references", + sql="SELECT * FROM __ref(orders)", + native_message="__ref must contain exactly one name argument", + expected_python_outcome="found 1 reference(s)", + ), + ReferenceMismatchTestCase( + description="python_raises_a_different_error", + sql="SELECT * FROM __ref(orders, customers)", + native_message="SQL reference contains an empty argument", + expected_python_outcome="raised '__ref must contain exactly one name argument'", + ), + ], + ids=lambda case: case.description, +) +def test_given_native_error_python_disagrees_with_when_extracting_then_raises_mismatch( + test_case: ReferenceMismatchTestCase, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv(COMPILER_ENGINE_ENV_VAR, CompilerEngine.NATIVE_PREVIEW.value) + + def failing_extract(*, sql: str, syntax: SqlLexicalSyntax) -> str: + del sql, syntax + return test_case.native_message + + monkeypatch.setattr(references, "extract_native_sql_references", failing_extract) + + with pytest.raises(NativeStageMismatchError) as raised: + _ = extract_sql_references(sql=test_case.sql, syntax=_GENERIC_SYNTAX) + + assert test_case.expected_python_outcome in str(raised.value) + assert repr(test_case.native_message) in str(raised.value) + + +@pytest.mark.parametrize( + "test_case", + [ + ReferenceScanBoundTestCase( + description="ten_thousand_references", + sql=_MANY_CALLS, + expected_outcome=10_000, + expected_maximum_seconds=0.5, + ), + ReferenceScanBoundTestCase( + description="one_hundred_thousand_call_arguments", + sql=_WIDE_CALL, + expected_outcome=1, + expected_maximum_seconds=0.5, + ), + ReferenceScanBoundTestCase( + description="thousand_nested_table_functions", + sql=_NESTED_CALLS, + expected_outcome=1_000, + expected_maximum_seconds=0.5, + ), + ReferenceScanBoundTestCase( + description="error_after_fifty_thousand_references", + sql=_ERROR_AFTER_MANY, + expected_outcome="__ref must contain exactly one name argument", + expected_maximum_seconds=0.5, + ), + ], + ids=lambda case: case.description, +) +def test_given_worst_case_reference_sql_when_extracting_natively_then_finishes_quickly( + test_case: ReferenceScanBoundTestCase, +) -> None: + started: float = time.thread_time() + outcome: tuple[CompileSqlReference, ...] | str | None = extract_native_sql_references( + sql=test_case.sql, syntax=_GENERIC_SYNTAX + ) + elapsed: float = time.thread_time() - started + + summary: int | str = _OUTCOME_SUMMARIES[type(outcome)](outcome) + + assert (summary, elapsed < test_case.expected_maximum_seconds) == ( + test_case.expected_outcome, + True, + ), elapsed + + +if __name__ == "__main__": + pytest.main([__file__, "-vv"]) diff --git a/tests/unit/src/sqlbuild/compiler/frontier/test_native_stage_tiers.py b/tests/unit/src/sqlbuild/compiler/frontier/test_native_stage_tiers.py index 5cb419510d..7c1e471057 100644 --- a/tests/unit/src/sqlbuild/compiler/frontier/test_native_stage_tiers.py +++ b/tests/unit/src/sqlbuild/compiler/frontier/test_native_stage_tiers.py @@ -49,6 +49,24 @@ stage=NativeStage.DECLARATION_SCOPES, expected_enabled=True, ), + NativeStageTierTestCase( + description="python_reference_extraction", + engine=CompilerEngine.PYTHON, + stage=NativeStage.REFERENCE_EXTRACTION, + expected_enabled=False, + ), + NativeStageTierTestCase( + description="native_reference_extraction", + engine=CompilerEngine.NATIVE, + stage=NativeStage.REFERENCE_EXTRACTION, + expected_enabled=False, + ), + NativeStageTierTestCase( + description="native_preview_reference_extraction", + engine=CompilerEngine.NATIVE_PREVIEW, + stage=NativeStage.REFERENCE_EXTRACTION, + expected_enabled=True, + ), ], ids=lambda case: case.description, ) From 6a28f13ae68c07926d29d85ae0640c378ba2cce0 Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Wed, 7 Oct 2026 16:10:29 +0100 Subject: [PATCH 3/3] feat(native): compare reference extraction in the compiler differential The failure corpus gains reference cases: wrong argument counts, an empty argument, an expression name, unclosed calls, and table functions without an argument list, with a single-quoted name or with an empty call argument. A harness test compares a project with table functions and references hidden in comments, strings and dollar quotes under both engines. --- .../_helpers/corpus/failure_cases.py | 4 + .../corpus/reference_failure_cases.py | 70 +++++++++++++++++ .../test_reference_differential.py | 75 +++++++++++++++++++ 3 files changed, 149 insertions(+) create mode 100644 scripts/compiler_differential/_helpers/corpus/reference_failure_cases.py create mode 100644 tests/e2e/scripts/compiler_differential/test_reference_differential.py diff --git a/scripts/compiler_differential/_helpers/corpus/failure_cases.py b/scripts/compiler_differential/_helpers/corpus/failure_cases.py index 8953c5cfef..d0ec753932 100644 --- a/scripts/compiler_differential/_helpers/corpus/failure_cases.py +++ b/scripts/compiler_differential/_helpers/corpus/failure_cases.py @@ -8,6 +8,9 @@ from scripts.compiler_differential._helpers.corpus.discovery_failure_cases import ( discovery_failure_cases, ) +from scripts.compiler_differential._helpers.corpus.reference_failure_cases import ( + reference_failure_cases, +) from scripts.compiler_differential._helpers.corpus.render_failure_cases import ( render_failure_cases, ) @@ -41,6 +44,7 @@ def all_failure_cases() -> tuple[FailureCase, ...]: *discovery_failure_cases(), *render_failure_cases(), *scope_failure_cases(), + *reference_failure_cases(), ) diff --git a/scripts/compiler_differential/_helpers/corpus/reference_failure_cases.py b/scripts/compiler_differential/_helpers/corpus/reference_failure_cases.py new file mode 100644 index 0000000000..9c9ebfd27a --- /dev/null +++ b/scripts/compiler_differential/_helpers/corpus/reference_failure_cases.py @@ -0,0 +1,70 @@ +"""Minimal projects whose model SQL holds a malformed reference call.""" + +from scripts.compiler_differential._helpers.corpus.case_builder import ( + failure_case, + mart_body_files, +) +from scripts.compiler_differential.models import FailureCase + +_SELECT: str = "SELECT order_id AS customer_id, 1 AS total_amount\n" + + +def _mart_from(*, name: str, from_clause: str, expected_message: str) -> FailureCase: + return failure_case( + name=name, + expected_code="P001", + expected_message=expected_message, + files=mart_body_files(f"{_SELECT}FROM {from_clause}\n"), + ) + + +def reference_failure_cases() -> tuple[FailureCase, ...]: + """Return the reference-extraction failure cases in a stable order.""" + + return ( + _mart_from( + name="reference-two-name-arguments", + from_clause='__ref("stg_orders", "orders")', + expected_message="__ref must contain exactly one name argument", + ), + _mart_from( + name="reference-dbt-three-arguments", + from_clause='__dbt_ref("shop", "orders", "extra")', + expected_message="__dbt_ref must contain one name argument or package/name arguments", + ), + _mart_from( + name="reference-empty-argument", + from_clause='__ref("stg_orders", )', + expected_message="SQL reference contains an empty argument", + ), + _mart_from( + name="reference-expression-name", + from_clause="__ref(concat('stg_', 'orders'))", + expected_message="__ref name argument must be a quoted string or identifier", + ), + _mart_from( + name="reference-unclosed-call", + from_clause='__ref("stg_orders"', + expected_message="SQL reference contains an unclosed parenthesis", + ), + _mart_from( + name="table-function-without-arguments", + from_clause='__table_fn("table_fn__orders_for") /* no call */', + expected_message="__table_fn must be followed by an argument list", + ), + _mart_from( + name="table-function-single-quoted-name", + from_clause="__table_fn('table_fn__orders_for')(1)", + expected_message="__table_fn name argument must be double quoted", + ), + _mart_from( + name="table-function-empty-call-argument", + from_clause='__table_fn("table_fn__orders_for")(1, , 2)', + expected_message="SQL reference contains an empty argument", + ), + _mart_from( + name="table-function-unclosed-call", + from_clause='__table_fn("table_fn__orders_for")(1 -- )', + expected_message="SQL table function call contains an unclosed parenthesis", + ), + ) diff --git a/tests/e2e/scripts/compiler_differential/test_reference_differential.py b/tests/e2e/scripts/compiler_differential/test_reference_differential.py new file mode 100644 index 0000000000..fb4140add6 --- /dev/null +++ b/tests/e2e/scripts/compiler_differential/test_reference_differential.py @@ -0,0 +1,75 @@ +"""The harness finds a project with table functions and hidden references identical by engine.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from scripts.compiler_differential.constants import FAILURE_BASE_FILES +from scripts.compiler_differential.main.differential import run_compiler_differential +from tests.e2e.scripts.compiler_differential._test_types import HarnessRunTestCase +from tests.e2e.scripts.compiler_differential.helpers import harness_arguments + +_REFERENCE_FILES: dict[str, str] = { + **FAILURE_BASE_FILES, + "functions/sql/orders_for.sql": ( + "FUNCTION (\n" + ' description "Orders for one customer above a floor",\n' + " arguments (p_customer INTEGER, p_floor DOUBLE),\n" + " returns table (\n order_id INTEGER,\n amount DOUBLE\n ),\n);\n\n" + 'SELECT order_id, amount\nFROM __ref("stg_orders")\n' + "WHERE customer_id = p_customer AND amount >= p_floor\n" + ), + "models/marts/customer_totals.sql": ( + 'MODEL (\n description "Order totals per customer",\n);\n\n' + '-- __ref("hidden_in_comment")\n' + "SELECT o.customer_id, SUM(f.amount) AS total_amount,\n" + ' \'__ref("hidden_in_string")\' AS note, $$__table_fn("hidden")(1)$$ AS raw_note\n' + 'FROM __ref("stg_orders") o\n' + 'CROSS JOIN __table_fn("orders_for")\n (10, /* floor, */ 1.5) f\n' + "GROUP BY o.customer_id\n" + ), +} + + +@pytest.mark.parametrize( + "test_case", + [ + HarnessRunTestCase( + description="table_functions_and_hidden_references", + extra_arguments=(), + expected_exit_code=0, + expected_lines=( + "OK project/table_function_orders", + "Compiler differential passed: 1 projects identical (python vs native-preview)", + ), + expected_patterns=(), + expected_absent=("DIFF",), + ), + ], + ids=lambda case: case.description, +) +def test_given_table_function_project_when_comparing_engines_then_harness_passes( + test_case: HarnessRunTestCase, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + project_dir: Path = tmp_path / "table_function_orders" + for relative_path, contents in _REFERENCE_FILES.items(): + path: Path = project_dir / relative_path + path.parent.mkdir(parents=True, exist_ok=True) + _ = path.write_text(contents, encoding="utf-8", newline="\n") + + exit_code: int = run_compiler_differential( + harness_arguments( + work_dir=tmp_path / "work", extra=test_case.extra_arguments, project=project_dir + ) + ) + + output: str = capsys.readouterr().out + assert exit_code == test_case.expected_exit_code, output + assert all(line in output for line in test_case.expected_lines), output + assert not any(text in output for text in test_case.expected_absent), output + + +if __name__ == "__main__": + pytest.main([__file__, "-vv"])