Skip to content

Commit 0e87c96

Browse files
authored
feat(native): report SQL test and scenario errors natively (#1046)
Scenario extraction, scope relationship scans and macro-test CTE scans return Python's exact scanner and classification errors from one native port of Python's top-level CTE scanner, under the adapter's lexical rules. Scenario source checks no longer fall back to Python on a reference error. Only Polyglot steps and non-ASCII classification still run in Python.
1 parent 2fb3381 commit 0e87c96

40 files changed

Lines changed: 1523 additions & 373 deletions

File tree

‎Cargo.lock‎

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎crates/README.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,11 @@ that can be tested and benchmarked with `cargo test` alone.
1111
| `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. |
1212
| `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. |
1313
| `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. |
14-
| `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. |
14+
| `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 and top-level CTEs, with Python's scanner errors. Anything it cannot reproduce exactly defers to Python. |
1515
| `sqlbuild-model-config` | Model configuration: MODEL header columns and audits, `${...}` template expansion with its environment and context reads, and the template and macro presence scans over authored config values, all read through a trait over the caller's values. Anything it cannot reproduce exactly defers to Python, which also raises every model config error. |
1616
| `sqlbuild-render` | Native rendering: the macro call scanner (a byte-for-byte port of Python's), splicing of rendered calls with code-point spans, and the in-compile memo of recorded macro calls and their replayable events, which can carry results across compiles through the shared native store. Anything it cannot reproduce exactly defers to Python. |
1717
| `sqlbuild-attachments` | Compile attachments for tests, audits, sources, functions, scenarios and seeds: attached generic audit argument merge, raw and quoted argument rendering, and severity and run scope resolution. Anything it cannot reproduce exactly defers to Python, which also raises every attachment error. |
18-
| `sqlbuild-analysis` | SQL analysis over polyglot: SQL tokens, query analysis, the binding catalog, semantic validation and usage, column references, type normalization, and SQL-test extraction, planning and rendering. Type normalization defers to Python for anything it cannot reproduce exactly. |
18+
| `sqlbuild-analysis` | SQL analysis over polyglot: SQL tokens, query analysis, the binding catalog, semantic validation and usage, column references, type normalization, and SQL-test and scenario extraction, planning and rendering. Type normalization defers to Python for anything it cannot reproduce exactly. |
1919
| `sqlbuild-rules` | Built-in rules and the rules engine, the custom-rule host, SQL lint, quality checks and formatting, rules configuration and the request models. It also owns the build identity script. |
2020
| `sqlbuild-python` | The only PyO3 crate: the `_native` module, its Python classes and functions, conversions from Python objects, and the process allocator. |
2121

@@ -31,7 +31,7 @@ sqlbuild-python
3131
```
3232

3333
`sqlbuild-config` and `sqlbuild-model-config` do not depend on the crates below them today, and
34-
`sqlbuild-scopes` uses only `sqlbuild-sqltext`; their place in the order fixes which crates may
34+
`sqlbuild-scopes` uses only `sqlbuild-sqltext` and `sqlbuild-core`; their place in the order fixes which crates may
3535
use them. The JSON emitter
3636
lives in `sqlbuild-core` so that any layer can produce text that must equal Python's
3737
`json.dumps` output.

‎crates/sqlbuild-analysis/Cargo.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ serde.workspace = true
2121
serde_json.workspace = true
2222
sha2.workspace = true
2323
sqlbuild-core.workspace = true
24+
sqlbuild-scopes.workspace = true
2425
sqlbuild-sqltext.workspace = true
2526

2627
[lints]

‎crates/sqlbuild-analysis/src/compiler/_helpers/sql_tests/extraction.rs‎

Lines changed: 5 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -69,15 +69,6 @@ struct TestRequest {
6969
authored: bool,
7070
}
7171

72-
/// How strictly top-level CTE names are read.
73-
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
74-
pub(crate) enum NameRule {
75-
/// Unquoted ASCII identifiers only; anything else is an error naming the CTE.
76-
Plain,
77-
/// Quoted and Unicode identifiers are read, and the caller decides what to do with them.
78-
Lenient,
79-
}
80-
8172
/// One extraction error, with help and the offending text where the author can act on it.
8273
#[derive(Debug, PartialEq, Eq)]
8374
pub(crate) struct Failure {
@@ -198,8 +189,7 @@ fn extract_test(test: &TestRequest, syntax: &LexicalSyntax) -> Result<Classified
198189
if test.authored {
199190
return authored_ctes(test, syntax);
200191
}
201-
let (ctes, _) =
202-
extract_ctes_with_quoting(&test.sql, &test.file_label, syntax, NameRule::Plain)?;
192+
let ctes: Vec<Cte> = scan_ctes(&test.sql, &test.file_label, syntax)?.ctes;
203193
let scope = TestScope {
204194
file: &test.file_label,
205195
syntax,
@@ -214,7 +204,7 @@ fn extract_test(test: &TestRequest, syntax: &LexicalSyntax) -> Result<Classified
214204

215205
/// The authored block's CTEs, or the located error of a CTE name; other errors read as no CTEs.
216206
fn authored_ctes(test: &TestRequest, syntax: &LexicalSyntax) -> Result<Classified, Failure> {
217-
match scan_ctes(&test.sql, &test.file_label, syntax, NameRule::Plain) {
207+
match scan_ctes(&test.sql, &test.file_label, syntax) {
218208
Ok(scanned) => Ok(Classified::Authored {
219209
ctes: scanned
220210
.ctes
@@ -228,31 +218,14 @@ fn authored_ctes(test: &TestRequest, syntax: &LexicalSyntax) -> Result<Classifie
228218
}
229219
}
230220

231-
/// The top-level CTEs and whether any CTE name was a quoted identifier.
232-
pub(crate) fn extract_ctes_with_quoting(
233-
sql: &str,
234-
file: &str,
235-
syntax: &LexicalSyntax,
236-
rule: NameRule,
237-
) -> Result<(Vec<Cte>, bool), Failure> {
238-
scan_ctes(sql, file, syntax, rule).map(|scanned| (scanned.ctes, scanned.quoted))
239-
}
240-
241221
/// The top-level CTEs of one test, with the code-point offset of each stripped body.
242222
struct ScannedCtes {
243223
ctes: Vec<Cte>,
244-
quoted: bool,
245224
body_starts: Vec<usize>,
246225
}
247226

248-
fn scan_ctes(
249-
sql: &str,
250-
file: &str,
251-
syntax: &LexicalSyntax,
252-
rule: NameRule,
253-
) -> Result<ScannedCtes, Failure> {
227+
fn scan_ctes(sql: &str, file: &str, syntax: &LexicalSyntax) -> Result<ScannedCtes, Failure> {
254228
let mut body_starts: Vec<usize> = Vec::new();
255-
let mut quoted = false;
256229
let mut index = skip_ignorable(sql, 0, syntax)?;
257230
index = consume_keyword(sql, index, "WITH").ok_or_else(|| {
258231
format!("SQL test '{file}' must declare mock CTEs and one __expected__<model> CTE in a top-level WITH clause")
@@ -264,12 +237,7 @@ fn scan_ctes(
264237
let mut ctes: Vec<Cte> = Vec::new();
265238
let mut seen: HashSet<String> = HashSet::new();
266239
loop {
267-
quoted |= matches!(byte_at(sql, index), Some(b'"' | b'`'));
268-
let (name, end) = match rule {
269-
NameRule::Plain => read_cte_name(sql, index, file, syntax)?,
270-
NameRule::Lenient => read_identifier(sql, index, syntax)
271-
.ok_or_else(|| format!("SQL test '{file}' expected a CTE name"))?,
272-
};
240+
let (name, end) = read_cte_name(sql, index, file, syntax)?;
273241
if !seen.insert(name.clone()) {
274242
return Err(format!("SQL test '{file}' defines duplicate CTE '{name}'").into());
275243
}
@@ -304,11 +272,7 @@ fn scan_ctes(
304272
"SQL test '{file}' must end after its CTEs; only an optional ceremonial top-level `SELECT 1` may follow them"
305273
).into());
306274
}
307-
Ok(ScannedCtes {
308-
ctes,
309-
quoted,
310-
body_starts,
311-
})
275+
Ok(ScannedCtes { ctes, body_starts })
312276
}
313277

314278
/// Read a top-level CTE name, which must be an unquoted ASCII identifier.
Lines changed: 155 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,21 @@
1-
//! Python's `extract_sql_scenario_ctes` for scenarios its top-level scanner reads exactly.
1+
//! Python's `extract_sql_scenario_ctes`: the exact top-level scan, then classification and errors.
2+
3+
use std::collections::{HashMap, HashSet};
24

35
use serde::Serialize;
6+
use sqlbuild_scopes::relationship_names::main::top_level_ctes::scan_top_level_ctes;
7+
use sqlbuild_scopes::relationship_names::models::{RelationshipSource, TopLevelCtes};
8+
use sqlbuild_sqltext::sql_scan::models::LexicalSyntax;
49

5-
use crate::compiler::_helpers::sql_tests::extraction::{
6-
Cte, NameRule, extract_ctes_with_quoting, generic_syntax, validate_independence,
7-
};
10+
type Cte = (String, String);
811

9-
/// Characters whose Python case mapping reaches ASCII, which Python's keyword match would accept.
10-
const CASE_MAPPED_TO_ASCII: [char; 20] = [
11-
'\u{df}', '\u{130}', '\u{131}', '\u{149}', '\u{17f}', '\u{1f0}', '\u{1e96}', '\u{1e97}',
12-
'\u{1e98}', '\u{1e99}', '\u{1e9a}', '\u{1e9e}', '\u{212a}', '\u{fb00}', '\u{fb01}', '\u{fb02}',
13-
'\u{fb03}', '\u{fb04}', '\u{fb05}', '\u{fb06}',
14-
];
12+
const SOURCE_PREFIX: &str = "__source__";
13+
const REF_PREFIX: &str = "__ref__";
14+
const SEED_PREFIX: &str = "__seed__";
15+
const DBT_REF_PREFIX: &str = "__dbt_ref__";
16+
const EXPECTED_PREFIX: &str = "__expected__";
17+
const ASSERT_PREFIX: &str = "__assert__";
18+
const MACRO_PREFIX: &str = "__macro__";
1519

1620
#[derive(Debug, Default, Serialize)]
1721
#[serde(rename_all = "camelCase")]
@@ -27,77 +31,169 @@ struct ClassifiedScenario {
2731
assertion_names: Vec<String>,
2832
}
2933

30-
pub(crate) fn extract_scenario_json(sql: &str, file: &str) -> Result<Option<String>, String> {
31-
if sql.chars().any(|character| {
32-
matches!(character, '\u{1c}'..='\u{1f}') || CASE_MAPPED_TO_ASCII.contains(&character)
33-
}) {
34-
return Ok(None);
34+
/// One scenario's scanner error, or its classification after any Polyglot independence check.
35+
#[derive(Debug, Default, Serialize)]
36+
#[serde(rename_all = "camelCase")]
37+
struct ScenarioOutcome {
38+
#[serde(skip_serializing_if = "Option::is_none")]
39+
scan_error: Option<String>,
40+
#[serde(skip_serializing_if = "Option::is_none")]
41+
independence: Option<Vec<Cte>>,
42+
#[serde(skip_serializing_if = "Option::is_none")]
43+
error: Option<String>,
44+
#[serde(skip_serializing_if = "Option::is_none")]
45+
scenario: Option<ClassifiedScenario>,
46+
}
47+
48+
pub(crate) fn extract_scenario_json(
49+
sql: &str,
50+
file: &str,
51+
syntax: &LexicalSyntax,
52+
) -> Result<Option<String>, String> {
53+
let outcome: ScenarioOutcome =
54+
match scan_top_level_ctes(sql, file, RelationshipSource::Scenario, syntax) {
55+
TopLevelCtes::Deferred => return Ok(None),
56+
TopLevelCtes::Failed(message) => ScenarioOutcome {
57+
scan_error: Some(message),
58+
..ScenarioOutcome::default()
59+
},
60+
TopLevelCtes::Scanned(ctes) => {
61+
let independence: Option<Vec<Cte>> =
62+
python_checks_independence(&ctes).then(|| ctes.clone());
63+
match classify(ctes, file) {
64+
Ok(scenario) => ScenarioOutcome {
65+
independence,
66+
scenario: Some(scenario),
67+
..ScenarioOutcome::default()
68+
},
69+
Err(message) => ScenarioOutcome {
70+
independence,
71+
error: Some(message),
72+
..ScenarioOutcome::default()
73+
},
74+
}
75+
}
76+
};
77+
serde_json::to_string(&outcome)
78+
.map(Some)
79+
.map_err(|error| error.to_string())
80+
}
81+
82+
/// Whether Python's independence check parses a body; when it does not, the check passes.
83+
fn python_checks_independence(ctes: &[Cte]) -> bool {
84+
let mut names_by_key: HashMap<String, &str> = HashMap::new();
85+
for (name, _) in ctes {
86+
names_by_key.insert(name.to_ascii_lowercase(), name);
3587
}
36-
let syntax = generic_syntax();
37-
let Ok((ctes, quoted)) = extract_ctes_with_quoting(sql, file, &syntax, NameRule::Lenient)
38-
else {
39-
return Ok(None);
88+
let keys_with_prefix = |prefix: &str| -> HashSet<&String> {
89+
names_by_key
90+
.iter()
91+
.filter(|(_, name)| name.starts_with(prefix))
92+
.map(|(key, _)| key)
93+
.collect()
4094
};
41-
if quoted || !ctes.iter().all(|cte| is_plain_identifier(&cte.0)) {
42-
return Ok(None);
95+
let expected: HashSet<&String> = keys_with_prefix(EXPECTED_PREFIX);
96+
let assertions: HashSet<&String> = keys_with_prefix(ASSERT_PREFIX);
97+
for (name, body) in ctes {
98+
let key: String = name.to_ascii_lowercase();
99+
let prohibited: &str = if expected.contains(&key) {
100+
ASSERT_PREFIX
101+
} else if assertions.contains(&key) {
102+
EXPECTED_PREFIX
103+
} else {
104+
continue;
105+
};
106+
if folded_body_may_contain(body, prohibited) {
107+
return true;
108+
}
43109
}
44-
if validate_independence(&ctes, file, &syntax).is_err() {
45-
return Ok(None);
110+
if expected.is_empty() || assertions.is_empty() {
111+
return false;
46112
}
47-
let Some(scenario) = classify(ctes) else {
48-
return Ok(None);
49-
};
50-
serde_json::to_string(&scenario)
51-
.map(Some)
52-
.map_err(|error| error.to_string())
113+
for (_, body) in ctes {
114+
for key in names_by_key.keys() {
115+
if folded_body_may_contain(body, key) {
116+
return true;
117+
}
118+
}
119+
}
120+
false
53121
}
54122

55-
/// Python's `_read_identifier` reads only `[A-Za-z_][A-Za-z0-9_]*` the same way.
56-
fn is_plain_identifier(name: &str) -> bool {
57-
let mut bytes = name.bytes();
58-
bytes
59-
.next()
60-
.is_some_and(|first| first.is_ascii_alphabetic() || first == b'_')
61-
&& bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
123+
/// Whether the body's Python `casefold` may contain the text; non-ASCII may fold onto ASCII.
124+
fn folded_body_may_contain(body: &str, lower_ascii: &str) -> bool {
125+
!body.is_ascii() || body.to_ascii_lowercase().contains(lower_ascii)
62126
}
63127

64-
fn classify(ctes: Vec<Cte>) -> Option<ClassifiedScenario> {
128+
fn classify(ctes: Vec<Cte>, file: &str) -> Result<ClassifiedScenario, String> {
65129
let mut scenario: ClassifiedScenario = ClassifiedScenario::default();
66130
for cte in ctes {
67-
let name: String = cte.0.clone();
68-
if let Some(value) = name.strip_prefix("__source__") {
69-
scenario.source_fixtures.push(required(value)?);
131+
let name: &str = &cte.0;
132+
if let Some(value) = name.strip_prefix(SOURCE_PREFIX) {
133+
scenario
134+
.source_fixtures
135+
.push(required(value, "__source__<source>", file)?);
70136
scenario.authored.push(cte);
71-
} else if let Some(value) = name.strip_prefix("__ref__") {
72-
scenario.ref_fixtures.push(required(value)?);
137+
} else if let Some(value) = name.strip_prefix(REF_PREFIX) {
138+
scenario
139+
.ref_fixtures
140+
.push(required(value, "__ref__<model>", file)?);
73141
scenario.authored.push(cte);
74-
} else if let Some(value) = name.strip_prefix("__seed__") {
75-
scenario.seed_fixtures.push(required(value)?);
142+
} else if let Some(value) = name.strip_prefix(SEED_PREFIX) {
143+
scenario
144+
.seed_fixtures
145+
.push(required(value, "__seed__<seed>", file)?);
76146
scenario.authored.push(cte);
77-
} else if let Some(value) = name.strip_prefix("__dbt_ref__") {
78-
scenario.dbt_ref_fixtures.push(required(value)?);
147+
} else if let Some(value) = name.strip_prefix(DBT_REF_PREFIX) {
148+
scenario.dbt_ref_fixtures.push(required(
149+
value,
150+
"__dbt_ref__<model> or __dbt_ref__<package>__<model>",
151+
file,
152+
)?);
79153
scenario.authored.push(cte);
80-
} else if let Some(value) = name.strip_prefix("__expected__") {
81-
scenario.expected_models.push(required(value)?);
154+
} else if let Some(value) = name.strip_prefix(EXPECTED_PREFIX) {
155+
scenario
156+
.expected_models
157+
.push(required(value, "__expected__<model>", file)?);
82158
scenario.expected.push(cte);
83-
} else if let Some(value) = name.strip_prefix("__assert__") {
84-
scenario.assertion_names.push(required(value)?);
159+
} else if let Some(value) = name.strip_prefix(ASSERT_PREFIX) {
160+
scenario
161+
.assertion_names
162+
.push(required(value, "__assert__<assertion>", file)?);
85163
scenario.assertions.push(cte);
86-
} else if name.starts_with("__macro__") {
87-
return None;
164+
} else if name.starts_with(MACRO_PREFIX) {
165+
return Err(format!(
166+
"SQL scenario '{file}' does not support macro mock CTE '{name}'. Scenarios run \
167+
real project macros; use SQL unit tests for macro mocks."
168+
));
88169
} else {
89170
scenario.authored.push(cte);
90171
}
91172
}
92-
let has_fixture: bool = !(scenario.source_fixtures.is_empty()
173+
if scenario.source_fixtures.is_empty()
93174
&& scenario.ref_fixtures.is_empty()
94175
&& scenario.seed_fixtures.is_empty()
95-
&& scenario.dbt_ref_fixtures.is_empty());
96-
let has_check: bool =
97-
!(scenario.expected_models.is_empty() && scenario.assertion_names.is_empty());
98-
(has_fixture && has_check).then_some(scenario)
176+
&& scenario.dbt_ref_fixtures.is_empty()
177+
{
178+
return Err(format!(
179+
"SQL scenario '{file}' must define at least one __source__*, __ref__*, __seed__*, or \
180+
__dbt_ref__* fixture CTE"
181+
));
182+
}
183+
if scenario.expected_models.is_empty() && scenario.assertion_names.is_empty() {
184+
return Err(format!(
185+
"SQL scenario '{file}' must define at least one __expected__<model> or \
186+
__assert__<assertion> CTE"
187+
));
188+
}
189+
Ok(scenario)
99190
}
100191

101-
fn required(value: &str) -> Option<String> {
102-
(!value.is_empty()).then(|| value.to_owned())
192+
fn required(value: &str, label: &str, file: &str) -> Result<String, String> {
193+
if value.is_empty() {
194+
return Err(format!(
195+
"SQL scenario '{file}' must use {label} to identify a target"
196+
));
197+
}
198+
Ok(value.to_owned())
103199
}

0 commit comments

Comments
 (0)