Skip to content

Commit 33fd340

Browse files
authored
feat(scenarios): reject quoted and materialized scenario CTE names like SQL tests (#1052)
* feat(scenarios): reject quoted and materialized scenario CTE names like SQL tests Scenarios and macro-test scope scans no longer fall back to a Polyglot re-parse. Quoted CTE names and AS [NOT] MATERIALIZED are located compile errors with help on every engine, matching SQL tests. * test(scenarios): cover scenario body spans and pass them in unit helpers
1 parent 4f5e8c6 commit 33fd340

37 files changed

Lines changed: 497 additions & 262 deletions

File tree

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

Lines changed: 78 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ const DIRECT_NAMES: [(&str, &str, &str); 3] = [
3535
"__table_fn_expected__",
3636
),
3737
];
38+
const TEST_CONTEXT: &str = "SQL test";
39+
const SCENARIO_CONTEXT: &str = "SQL scenario";
3840
const DECLARATION_CALLS: [&str; 4] = ["enum", "const", "var", "param"];
3941
/// Longest projection the alias help repeats verbatim.
4042
const ALIAS_HELP_EXPRESSION_LIMIT: usize = 80;
@@ -67,6 +69,9 @@ struct TestRequest {
6769
/// Read only the CTE names and body offsets of the authored block, before any expansion.
6870
#[serde(default)]
6971
authored: bool,
72+
/// The authored text is a scenario rather than a SQL test block.
73+
#[serde(default)]
74+
scenario: bool,
7075
}
7176

7277
/// One extraction error, with help and the offending text where the author can act on it.
@@ -189,7 +194,7 @@ fn extract_test(test: &TestRequest, syntax: &LexicalSyntax) -> Result<Classified
189194
if test.authored {
190195
return authored_ctes(test, syntax);
191196
}
192-
let ctes: Vec<Cte> = scan_ctes(&test.sql, &test.file_label, syntax)?.ctes;
197+
let ctes: Vec<Cte> = scan_ctes(&test.sql, &test.file_label, TEST_CONTEXT, syntax)?.ctes;
193198
let scope = TestScope {
194199
file: &test.file_label,
195200
syntax,
@@ -204,7 +209,12 @@ fn extract_test(test: &TestRequest, syntax: &LexicalSyntax) -> Result<Classified
204209

205210
/// The authored block's CTEs, or the located error of a CTE name; other errors read as no CTEs.
206211
fn authored_ctes(test: &TestRequest, syntax: &LexicalSyntax) -> Result<Classified, Failure> {
207-
match scan_ctes(&test.sql, &test.file_label, syntax) {
212+
let context: &str = if test.scenario {
213+
SCENARIO_CONTEXT
214+
} else {
215+
TEST_CONTEXT
216+
};
217+
match scan_ctes(&test.sql, &test.file_label, context, syntax) {
208218
Ok(scanned) => Ok(Classified::Authored {
209219
ctes: scanned
210220
.ctes
@@ -218,13 +228,30 @@ fn authored_ctes(test: &TestRequest, syntax: &LexicalSyntax) -> Result<Classifie
218228
}
219229
}
220230

231+
/// Who owns the CTEs being read, which names them in errors, and the adapter's lexical rules.
232+
struct CteOwner<'a> {
233+
file: &'a str,
234+
context: &'a str,
235+
syntax: &'a LexicalSyntax,
236+
}
237+
221238
/// The top-level CTEs of one test, with the code-point offset of each stripped body.
222239
struct ScannedCtes {
223240
ctes: Vec<Cte>,
224241
body_starts: Vec<usize>,
225242
}
226243

227-
fn scan_ctes(sql: &str, file: &str, syntax: &LexicalSyntax) -> Result<ScannedCtes, Failure> {
244+
fn scan_ctes(
245+
sql: &str,
246+
file: &str,
247+
context: &str,
248+
syntax: &LexicalSyntax,
249+
) -> Result<ScannedCtes, Failure> {
250+
let owner = CteOwner {
251+
file,
252+
context,
253+
syntax,
254+
};
228255
let mut body_starts: Vec<usize> = Vec::new();
229256
let mut index = skip_ignorable(sql, 0, syntax)?;
230257
index = consume_keyword(sql, index, "WITH").ok_or_else(|| {
@@ -237,7 +264,7 @@ fn scan_ctes(sql: &str, file: &str, syntax: &LexicalSyntax) -> Result<ScannedCte
237264
let mut ctes: Vec<Cte> = Vec::new();
238265
let mut seen: HashSet<String> = HashSet::new();
239266
loop {
240-
let (name, end) = read_cte_name(sql, index, file, syntax)?;
267+
let (name, end) = read_cte_name(sql, index, &owner)?;
241268
if !seen.insert(name.clone()) {
242269
return Err(format!("SQL test '{file}' defines duplicate CTE '{name}'").into());
243270
}
@@ -252,8 +279,11 @@ fn scan_ctes(sql: &str, file: &str, syntax: &LexicalSyntax) -> Result<ScannedCte
252279
index = consume_keyword(sql, index, "AS")
253280
.ok_or_else(|| format!("SQL test '{file}' expected keyword AS"))?;
254281
index = skip_ignorable(sql, index, syntax)?;
282+
if let Some(failure) = materialization_failure(sql, index, &owner, &name)? {
283+
return Err(failure);
284+
}
255285
if byte_at(sql, index) != Some(b'(') {
256-
return Err(format!("SQL test '{file}' CTE '{name}' must use AS (...)").into());
286+
return Err(format!("{context} '{file}' CTE '{name}' must use AS (...)").into());
257287
}
258288
let close = matching_paren(sql, index, "SQL test", syntax)?;
259289
let body = &sql[index + 1..close];
@@ -279,9 +309,9 @@ fn scan_ctes(sql: &str, file: &str, syntax: &LexicalSyntax) -> Result<ScannedCte
279309
fn read_cte_name(
280310
sql: &str,
281311
start: usize,
282-
file: &str,
283-
syntax: &LexicalSyntax,
312+
owner: &CteOwner<'_>,
284313
) -> Result<(String, usize), Failure> {
314+
let (file, context, syntax) = (owner.file, owner.context, owner.syntax);
285315
if let Some(quote) = byte_at(sql, start).filter(|byte| matches!(byte, b'"' | b'`' | b'[')) {
286316
let end = if quote == b'[' {
287317
sql[start..]
@@ -290,28 +320,59 @@ fn read_cte_name(
290320
} else {
291321
skip_non_code(sql, start, syntax)?
292322
};
293-
return Err(cte_name_failure(sql, start, end, file));
323+
return Err(cte_name_failure(sql, start, end, owner));
294324
}
295-
let Some(first) = char_at(sql, start).filter(|first| *first == '_' || first.is_alphabetic())
296-
else {
297-
return Err(format!("SQL test '{file}' expected a CTE name").into());
325+
let Some(first) = char_at(sql, start).filter(|first| is_identifier_continue(*first)) else {
326+
return Err(format!("{context} '{file}' expected a CTE name").into());
298327
};
299328
let mut end = start + first.len_utf8();
300329
while let Some(character) =
301330
char_at(sql, end).filter(|character| is_identifier_continue(*character))
302331
{
303332
end += character.len_utf8();
304333
}
305-
if sql[start..end]
306-
.bytes()
307-
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
334+
if (first == '_' || first.is_ascii_alphabetic())
335+
&& sql[start..end]
336+
.bytes()
337+
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
308338
{
309339
return Ok((sql[start..end].to_owned(), end));
310340
}
311-
Err(cte_name_failure(sql, start, end, file))
341+
Err(cte_name_failure(sql, start, end, owner))
342+
}
343+
344+
/// Python's error for `AS MATERIALIZED` or `AS NOT MATERIALIZED`, located at the hint.
345+
fn materialization_failure(
346+
sql: &str,
347+
start: usize,
348+
owner: &CteOwner<'_>,
349+
name: &str,
350+
) -> Result<Option<Failure>, Failure> {
351+
let (hint, end) = match consume_keyword(sql, start, "NOT") {
352+
Some(not_end) => {
353+
let index = skip_ignorable(sql, not_end, owner.syntax)?;
354+
match consume_keyword(sql, index, "MATERIALIZED") {
355+
Some(end) => ("NOT MATERIALIZED", end),
356+
None => return Ok(None),
357+
}
358+
}
359+
None => match consume_keyword(sql, start, "MATERIALIZED") {
360+
Some(end) => ("MATERIALIZED", end),
361+
None => return Ok(None),
362+
},
363+
};
364+
Ok(Some(Failure {
365+
message: format!(
366+
"{} '{}' CTE '{name}' must not use AS {hint}; materialization hints are not supported in {} CTEs",
367+
owner.context, owner.file, owner.context
368+
),
369+
help: Some(format!("remove {hint} and write {name} AS (...)")),
370+
token: Some((sql[start..end].to_owned(), sql[..start].chars().count())),
371+
}))
312372
}
313373

314-
fn cte_name_failure(sql: &str, start: usize, end: usize, file: &str) -> Failure {
374+
fn cte_name_failure(sql: &str, start: usize, end: usize, owner: &CteOwner<'_>) -> Failure {
375+
let (file, context) = (owner.file, owner.context);
315376
let token = &sql[start..end];
316377
let shown = if matches!(byte_at(sql, start), Some(b'"' | b'`' | b'[')) {
317378
token.to_owned()
@@ -338,7 +399,7 @@ fn cte_name_failure(sql: &str, start: usize, end: usize, file: &str) -> Failure
338399
}
339400
Failure {
340401
message: format!(
341-
"SQL test '{file}' CTE name {shown} must be an unquoted identifier of ASCII letters, digits and underscores"
402+
"{context} '{file}' CTE name {shown} must be an unquoted identifier of ASCII letters, digits and underscores"
342403
),
343404
help: Some(format!(
344405
"rename the CTE, for example {suggestion}; quoted CTE names and names with $ or non-ASCII characters are not supported"

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

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,10 @@ struct ClassifiedScenario {
3131
assertion_names: Vec<String>,
3232
}
3333

34-
/// One scenario's scanner error, or its classification after any Polyglot independence check.
34+
/// One scenario's error, or its classification after any Polyglot independence check.
3535
#[derive(Debug, Default, Serialize)]
3636
#[serde(rename_all = "camelCase")]
3737
struct ScenarioOutcome {
38-
#[serde(skip_serializing_if = "Option::is_none")]
39-
scan_error: Option<String>,
4038
#[serde(skip_serializing_if = "Option::is_none")]
4139
independence: Option<Vec<Cte>>,
4240
#[serde(skip_serializing_if = "Option::is_none")]
@@ -54,7 +52,7 @@ pub(crate) fn extract_scenario_json(
5452
match scan_top_level_ctes(sql, file, RelationshipSource::Scenario, syntax) {
5553
TopLevelCtes::Deferred => return Ok(None),
5654
TopLevelCtes::Failed(message) => ScenarioOutcome {
57-
scan_error: Some(message),
55+
error: Some(message),
5856
..ScenarioOutcome::default()
5957
},
6058
TopLevelCtes::Scanned(ctes) => {

‎crates/sqlbuild-analysis/src/compiler/tests/helpers.rs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1991,8 +1991,8 @@ pub(crate) fn rejected_outcome(
19911991
}
19921992

19931993
/// The authored-block reading of `sql`: its CTEs with body offsets, or its first error.
1994-
pub(crate) fn authored_ctes_response(sql: &str) -> Value {
1994+
pub(crate) fn authored_ctes_response(sql: &str, scenario: bool) -> Value {
19951995
batch_response(
1996-
&json!({"tests": [{"sql": sql, "fileLabel": "tests/t.sql", "mode": "model", "authored": true}]}),
1996+
&json!({"tests": [{"sql": sql, "fileLabel": "tests/t.sql", "mode": "model", "authored": true, "scenario": scenario}]}),
19971997
)
19981998
}

‎crates/sqlbuild-analysis/src/compiler/tests/test_sql_scenario_extraction.rs‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,16 +64,23 @@ fn given_scenarios_when_extracting_natively_then_python_outcomes_or_deferrals_ar
6464
),
6565
},
6666
ScenarioExtractionTestCase {
67-
description: "a quoted CTE name is a scanner error Python may still accept with Polyglot",
67+
description: "a quoted CTE name is Python's scanner error",
6868
sql: "WITH \"__source__raw\" AS (SELECT 1), __expected__orders AS (SELECT 1 AS id)",
6969
expected_json: Some(
70-
r#"{"scanError":"SQL scenario 'tests/scenarios/orders.sql' expected a CTE name"}"#,
70+
r#"{"error":"SQL scenario 'tests/scenarios/orders.sql' expected a CTE name"}"#,
7171
),
7272
},
7373
ScenarioExtractionTestCase {
74-
description: "an unclosed body is a scanner error naming the scenario context",
74+
description: "an unclosed body is Python's scanner error naming the scenario context",
7575
sql: "WITH __source__raw AS (SELECT 1",
76-
expected_json: Some(r#"{"scanError":"SQL scenario contains an unclosed parenthesis"}"#),
76+
expected_json: Some(r#"{"error":"SQL scenario contains an unclosed parenthesis"}"#),
77+
},
78+
ScenarioExtractionTestCase {
79+
description: "a materialization hint is Python's scanner error",
80+
sql: "WITH __source__raw AS NOT MATERIALIZED (SELECT 1), __expected__orders AS (SELECT 1)",
81+
expected_json: Some(
82+
r#"{"error":"SQL scenario 'tests/scenarios/orders.sql' CTE '__source__raw' must not use AS NOT MATERIALIZED; materialization hints are not supported in SQL scenario CTEs"}"#,
83+
),
7784
},
7885
ScenarioExtractionTestCase {
7986
description: "a keyword Python would match by case mapping defers",

‎crates/sqlbuild-analysis/src/compiler/tests/test_sql_test_extraction_rules.rs‎

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,7 @@ fn given_authored_sql_tests_when_extracting_then_each_rule_holds() {
385385
fn given_authored_blocks_when_reading_ctes_then_offsets_and_name_errors_are_exact() {
386386
let test_cases = [
387387
AuthoredCtesTestCase {
388+
scenario: false,
388389
description: "body offsets count code points and skip leading whitespace",
389390
sql: "-- café\nWITH h AS ( SELECT 'é' AS a), __ref__orders AS (\n SELECT 1)",
390391
expected_response: json!({"tests": [{"kind": "authored", "ctes": [
@@ -393,6 +394,7 @@ fn given_authored_blocks_when_reading_ctes_then_offsets_and_name_errors_are_exac
393394
]}]}),
394395
},
395396
AuthoredCtesTestCase {
397+
scenario: false,
396398
description: "a CTE name error keeps its code-point offset",
397399
sql: "-- é\nWITH h AS (SELECT 1), café AS (SELECT 2)",
398400
expected_response: json!({"error": {
@@ -404,6 +406,55 @@ fn given_authored_blocks_when_reading_ctes_then_offsets_and_name_errors_are_exac
404406
}}),
405407
},
406408
AuthoredCtesTestCase {
409+
scenario: false,
410+
description: "a materialization hint is located at the hint with help",
411+
sql: "WITH h AS (SELECT 1),\n__ref__orders AS not\nmaterialized (SELECT 2)",
412+
expected_response: json!({"error": {
413+
"index": 0,
414+
"message": "SQL test 'tests/t.sql' CTE '__ref__orders' must not use AS NOT MATERIALIZED; materialization hints are not supported in SQL test CTEs",
415+
"help": "remove NOT MATERIALIZED and write __ref__orders AS (...)",
416+
"token": "not\nmaterialized",
417+
"tokenOffset": 39,
418+
}}),
419+
},
420+
AuthoredCtesTestCase {
421+
scenario: true,
422+
description: "a scenario's quoted CTE name names the scenario",
423+
sql: "WITH \"__source__raw_orders\" AS (SELECT 1), __expected__orders AS (SELECT 1)",
424+
expected_response: json!({"error": {
425+
"index": 0,
426+
"message": "SQL scenario 'tests/t.sql' CTE name \"__source__raw_orders\" must be an unquoted identifier of ASCII letters, digits and underscores",
427+
"help": "rename the CTE, for example __source__raw_orders; quoted CTE names and names with $ or non-ASCII characters are not supported",
428+
"token": "\"__source__raw_orders\"",
429+
"tokenOffset": 5,
430+
}}),
431+
},
432+
AuthoredCtesTestCase {
433+
scenario: true,
434+
description: "a scenario's materialization hint names the scenario",
435+
sql: "WITH __source__raw_orders AS MATERIALIZED (SELECT 1)",
436+
expected_response: json!({"error": {
437+
"index": 0,
438+
"message": "SQL scenario 'tests/t.sql' CTE '__source__raw_orders' must not use AS MATERIALIZED; materialization hints are not supported in SQL scenario CTEs",
439+
"help": "remove MATERIALIZED and write __source__raw_orders AS (...)",
440+
"token": "MATERIALIZED",
441+
"tokenOffset": 29,
442+
}}),
443+
},
444+
AuthoredCtesTestCase {
445+
scenario: false,
446+
description: "a CTE name starting with a digit is located with help",
447+
sql: "WITH 2024_orders AS (SELECT 1)",
448+
expected_response: json!({"error": {
449+
"index": 0,
450+
"message": "SQL test 'tests/t.sql' CTE name '2024_orders' must be an unquoted identifier of ASCII letters, digits and underscores",
451+
"help": "rename the CTE, for example cte_2024_orders; quoted CTE names and names with $ or non-ASCII characters are not supported",
452+
"token": "2024_orders",
453+
"tokenOffset": 5,
454+
}}),
455+
},
456+
AuthoredCtesTestCase {
457+
scenario: false,
407458
description: "other errors are left to extraction and read as no CTEs",
408459
sql: "WITH h AS (SELECT 1) SELECT * FROM h",
409460
expected_response: json!({"tests": [{"kind": "authored", "ctes": []}]}),
@@ -412,7 +463,7 @@ fn given_authored_blocks_when_reading_ctes_then_offsets_and_name_errors_are_exac
412463

413464
for test_case in test_cases {
414465
assert_eq!(
415-
authored_ctes_response(test_case.sql),
466+
authored_ctes_response(test_case.sql, test_case.scenario),
416467
test_case.expected_response,
417468
"{}",
418469
test_case.description

‎crates/sqlbuild-analysis/src/compiler/tests/test_types.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ pub(crate) struct ExtractionRuleTestCase {
162162

163163
pub(crate) struct AuthoredCtesTestCase {
164164
pub(crate) description: &'static str,
165+
pub(crate) scenario: bool,
165166
pub(crate) sql: &'static str,
166167
pub(crate) expected_response: serde_json::Value,
167168
}

‎crates/sqlbuild-discovery/src/sql_tests/_helpers/scenario_parsing.rs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,15 @@ pub(crate) fn parse_scenario_file(
3838
contents[..header_start].matches('\n').count() + 1,
3939
)?;
4040
let sql_body: String = python_cleandoc(options.python, &contents[sql_start..]);
41+
let span_start: usize = contents[..sql_start].chars().count();
42+
let body_span: (usize, usize) = (
43+
span_start,
44+
span_start + contents[sql_start..].chars().count(),
45+
);
4146
Ok(DiscoveredScenarioFile {
4247
contents,
4348
header_values,
4449
sql_body,
50+
body_span,
4551
})
4652
}

‎crates/sqlbuild-discovery/src/sql_tests/models.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,6 @@ pub struct DiscoveredScenarioFile {
3636
pub contents: String,
3737
pub header_values: Vec<(String, AuthoredValue)>,
3838
pub sql_body: String,
39+
/// Code-point span of the authored SQL after the header, which `sql_body` cleans.
40+
pub body_span: (usize, usize),
3941
}

‎crates/sqlbuild-discovery/src/sql_tests/tests/helpers.rs‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,3 +94,10 @@ pub(super) fn statement_failures(contents: &str) -> [Option<(String, Option<Stri
9494
.map(failure),
9595
]
9696
}
97+
98+
/// The code-point span of a parsed scenario's SQL after its header, or `None` if parsing failed.
99+
pub(super) fn scenario_body_span(contents: &str) -> Option<(usize, usize)> {
100+
parse_scenario_text(FILE_PATH, contents.to_owned(), &options())
101+
.ok()
102+
.map(|file| file.body_span)
103+
}

0 commit comments

Comments
 (0)