Skip to content

Commit 2357ce2

Browse files
authored
fix: lint SQL macro bodies as compiled and pass SQL macro expansions to Python macros intact (#1236)
* fix: lint SQL macro bodies as compiled and pass SQL macro expansions to Python macros intact Linting a SQL macro file read a parameter followed by words and a parenthesis, such as `@value IS NULL OR TRIM(@value) = ''`, as an unprefixed macro call (L005), and a body opening with two parentheses reached the native parser as a statement it rejected (rules-unevaluated). - One rule decides an unprefixed call everywhere: `@name(` with the parenthesis right after the name. The Python macro call scanner, the SQL macro lexer, parameter references and the bare-call check share it, so `@name IS NULL OR f(x)` in model SQL is no longer P014 either. `@const`, `@enum` and `@param` stay declarations with or without a space before their parenthesis. - Lint replaces a SQL macro body's parameters by sentinels with the compiler's own parameter scan, lints an expression body as the `SELECT` the compiler parses it as, and a CTE-list body with its ceremonial `SELECT 1` and its CTEs counted as used by the caller. Findings in the generated frame are dropped. - A SQL macro expansion passed to a Python macro is spliced as a string literal with `"` written as `\x22`, which the call scanner and the argument parser read the same way, so quotes, backslashes, line breaks and comments reach the macro unchanged. - An expansion ending in a line comment gets a line break only when text would follow it on the same line, so the caller's text stays code and calls already last on their line compile unchanged. - A macro call the scan cannot read is a MacroArgumentError naming the call, file, line and column instead of a location-less P001. - Rules on a caller skip comments a SQL macro expansion generated; the macro file is where they are judged, and compiled SQL keeps them. Comments from Python macro output are judged as before. * fix: build authored lint lookups after the lint batch so SQL macro comment blanking adds no peak memory
1 parent 2838635 commit 2357ce2

38 files changed

Lines changed: 1366 additions & 98 deletions

File tree

‎crates/sqlbuild-analysis/src/sql_macros/_helpers/body.rs‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,39 @@ pub(crate) fn expand_body(
295295
Ok(BodyExpansion { sql, names })
296296
}
297297

298+
/// `sql`, with a line break when it ends in a `--` comment and `followed` text would join that line.
299+
pub(crate) fn with_line_comment_closed(sql: &str, followed: bool) -> String {
300+
let mut sql: String = sql.to_owned();
301+
if !followed {
302+
return sql;
303+
}
304+
let Ok(scanned) = tokens(&sql) else {
305+
return sql;
306+
};
307+
let mut rest: &str = &sql[scanned.last().map_or(0, |last| last.end)..];
308+
let mut open_line_comment: bool = false;
309+
loop {
310+
rest = rest.trim_start();
311+
if let Some(comment) = rest.strip_prefix("--") {
312+
match comment.split_once('\n') {
313+
Some((_, after)) => rest = after,
314+
None => {
315+
open_line_comment = true;
316+
break;
317+
}
318+
}
319+
} else if let Some(comment) = rest.strip_prefix("/*") {
320+
rest = comment.split_once("*/").map_or("", |(_, after)| after);
321+
} else {
322+
break;
323+
}
324+
}
325+
if open_line_comment {
326+
sql.push('\n');
327+
}
328+
sql
329+
}
330+
298331
/// The text after its first token, the body's `WITH` or `SELECT`, trimmed.
299332
fn without_leading_keyword(text: &str) -> String {
300333
match tokens(text) {

‎crates/sqlbuild-analysis/src/sql_macros/_helpers/definition.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ fn unclosed_body(name: &str, location: SqlMacroLocation) -> SqlMacroError {
106106
}
107107

108108
/// The form a body's first token decides.
109-
fn body_form(body: &str, scanned: &[Token]) -> SqlMacroForm {
109+
pub(crate) fn body_form(body: &str, scanned: &[Token]) -> SqlMacroForm {
110110
match scanned.first() {
111111
Some(first) if first.is_word(body, WITH_KEYWORD) => SqlMacroForm::CteList,
112112
Some(first) if first.is_word(body, SELECT_KEYWORD) => SqlMacroForm::ColumnList,

‎crates/sqlbuild-analysis/src/sql_macros/_helpers/expansion.rs‎

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use crate::compiler::main::function_type_validation::function_type_error;
66
use crate::sql_macros::_helpers::arguments::{
77
ShapeContext, argument_shape, bind_arguments, check_shape, is_atomic, split_arguments,
88
};
9-
use crate::sql_macros::_helpers::body::expand_body;
9+
use crate::sql_macros::_helpers::body::{expand_body, with_line_comment_closed};
1010
use crate::sql_macros::_helpers::lexing::{AtConstruct, at_constructs, outer_sql_calls};
1111
use crate::sql_macros::_helpers::lexing::{Token, line_column, tokens};
1212
use crate::sql_macros::_helpers::literal_positions::{
@@ -209,7 +209,10 @@ pub(crate) fn expand_call<H: SqlMacroHost>(
209209
let expansion: BodyExpansion = expand_body(&definition, &body, &rendered)
210210
.map_err(|error| error.at(body_location(&definition)))?;
211211
if call.in_python {
212-
return Ok((python_string(&expansion.sql), inserted));
212+
return Ok((
213+
python_string(&with_line_comment_closed(&expansion.sql, true)),
214+
inserted,
215+
));
213216
}
214217
if matches!(context.scope, SqlMacroScope::Caller) && context.stack.is_empty() {
215218
let check: SqlMacroCheck = SqlMacroCheck {
@@ -229,8 +232,17 @@ pub(crate) fn expand_call<H: SqlMacroHost>(
229232
(call.start, call.end),
230233
call.replaced_end,
231234
);
232-
placed(&definition, (&site, inserted), &expansion, context.owner)
233-
.map_err(ExpansionFailure::Macro)
235+
let line_continues: bool = context.text[call.replaced_end..]
236+
.split('\n')
237+
.next()
238+
.is_some_and(|rest| !rest.trim().is_empty());
239+
placed(
240+
&definition,
241+
(&site, inserted, line_continues),
242+
&expansion,
243+
context.owner,
244+
)
245+
.map_err(ExpansionFailure::Macro)
234246
}
235247

236248
/// One bound argument checked against its parameter and rendered from `scope`.
@@ -306,14 +318,14 @@ fn rendered_argument<H: SqlMacroHost>(
306318
})
307319
}
308320

309-
/// `sql` as a double-quoted Python string literal.
321+
/// `sql` as a double-quoted Python string literal holding no `"` and no line break.
310322
fn python_string(sql: &str) -> String {
311323
let mut quoted: String = String::with_capacity(sql.len() + 2);
312324
quoted.push('"');
313325
for character in sql.chars() {
314326
match character {
315327
'\\' => quoted.push_str("\\\\"),
316-
'"' => quoted.push_str("\\\""),
328+
'"' => quoted.push_str("\\x22"),
317329
'\n' => quoted.push_str("\\n"),
318330
'\r' => quoted.push_str("\\r"),
319331
'\t' => quoted.push_str("\\t"),
@@ -327,10 +339,17 @@ fn python_string(sql: &str) -> String {
327339
/// The expansion placed at the call, or why it cannot stand there or clashes.
328340
fn placed(
329341
definition: &SqlMacroDefinition,
330-
(site, inserted): (&CallSite<'_>, InsertedNames),
342+
(site, inserted, line_continues): (&CallSite<'_>, InsertedNames, bool),
331343
expansion: &BodyExpansion,
332344
owner: &str,
333345
) -> Result<(String, InsertedNames), SqlMacroError> {
346+
let closed = |after: &str| {
347+
let followed: bool = match after.chars().next() {
348+
None => line_continues,
349+
Some(next) => next != '\n' && next != '\r',
350+
};
351+
with_line_comment_closed(&expansion.sql, followed)
352+
};
334353
let position: CallPosition = site.position();
335354
let name: &str = &definition.name;
336355
match definition.form {
@@ -341,9 +360,9 @@ fn placed(
341360
}
342361
let comma: &str = site.expression_separator();
343362
if site.expression_needs_parentheses() && !is_atomic(&expansion.sql) {
344-
Ok((format!("({}){comma}", expansion.sql), inserted))
363+
Ok((format!("({}){comma}", closed(")")), inserted))
345364
} else {
346-
Ok((format!("{}{comma}", expansion.sql), inserted))
365+
Ok((format!("{}{comma}", closed(comma)), inserted))
347366
}
348367
}
349368
SqlMacroForm::CteList => {
@@ -365,7 +384,7 @@ fn placed(
365384
("CTE", owner),
366385
)?;
367386
let (before, after) = site.cte_separators();
368-
Ok((format!("{before}{}{after}", expansion.sql), inserted))
387+
Ok((format!("{before}{}{after}", closed(after)), inserted))
369388
}
370389
SqlMacroForm::ColumnList => {
371390
if position != CallPosition::SelectList {
@@ -395,7 +414,7 @@ fn placed(
395414
("column", owner),
396415
)?;
397416
let (before, after) = site.column_separators();
398-
Ok((format!("{before}{}{after}", expansion.sql), inserted))
417+
Ok((format!("{before}{}{after}", closed(after)), inserted))
399418
}
400419
}
401420
}

‎crates/sqlbuild-analysis/src/sql_macros/_helpers/lexing.rs‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
//! A comment- and quote-aware SQL token scan, and the `@...` constructs it finds.
22
3+
use sqlbuild_render::macro_calls::main::opens_bare_call::opens_bare_call;
4+
35
use crate::sql_macros::constants::SQL_KIND_WORD;
46
use crate::sql_macros::errors::LexError;
57

@@ -45,7 +47,7 @@ impl Token {
4547

4648
const PUNCTUATION: &[u8] = b"()[]{},.;";
4749
/// The `@word(...)` declaration forms that are not macro calls.
48-
const DECLARATION_WORDS: [&str; 3] = ["const", "enum", "param"];
50+
pub(crate) const DECLARATION_WORDS: [&str; 3] = ["const", "enum", "param"];
4951

5052
/// Every token of `sql`; text unclosed under doubled-quote escapes is reread with backslash ones.
5153
pub(crate) fn tokens(sql: &str) -> Result<Vec<Token>, LexError> {
@@ -341,7 +343,7 @@ pub(crate) fn at_constructs(sql: &str) -> Result<Vec<AtConstruct>, LexError> {
341343
});
342344
continue;
343345
}
344-
if scanned.get(index + 2).is_some_and(|open| open.is(sql, "(")) {
346+
if opens_bare_call(sql, word.end) {
345347
found.push(AtConstruct::Bare {
346348
name: word_text.to_owned(),
347349
start: token.start,

‎crates/sqlbuild-analysis/src/sql_macros/_helpers/parameters.rs‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
//! `@name` parameter references in a SQL macro body, as generic audits write their parameters.
22
3-
use crate::sql_macros::_helpers::lexing::{Token, TokenKind};
3+
use sqlbuild_render::macro_calls::main::opens_bare_call::opens_bare_call;
4+
5+
use crate::sql_macros::_helpers::lexing::{DECLARATION_WORDS, Token, TokenKind};
46

57
const PLACEHOLDER_PREFIX: &str = "__sqlbuild_parameter__";
68
/// Words that may follow a table reference without being its alias.
@@ -68,10 +70,12 @@ pub(crate) fn parameter_references(sql: &str, scanned: &[Token]) -> Vec<Paramete
6870
continue;
6971
};
7072
let after: Option<&Token> = scanned.get(index + 2);
71-
if after.is_some_and(|open| open.is(sql, "(")) {
73+
let name: &str = word.text(sql);
74+
let declaration: bool =
75+
DECLARATION_WORDS.contains(&name) && after.is_some_and(|open| open.is(sql, "("));
76+
if declaration || opens_bare_call(sql, word.end) {
7277
continue;
7378
}
74-
let name: &str = word.text(sql);
7579
if matches!(name, "sql" | "py")
7680
&& after.is_some_and(|dot| dot.is(sql, ".") && dot.start == word.end)
7781
&& scanned.get(index + 4).is_some_and(|open| open.is(sql, "("))

‎crates/sqlbuild-analysis/src/sql_macros/main/bare_macro_calls.rs‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
use crate::sql_macros::_helpers::lexing::{AtConstruct, at_constructs};
22
use crate::sql_macros::constants::NON_ASCII_BYTE;
3+
use sqlbuild_render::macro_calls::main::opens_bare_call::opens_bare_call;
34

45
/// The name and byte offset of each `@name(...)` call in `text` that names no macro kind.
56
#[must_use]
67
pub fn bare_macro_calls(text: &str) -> Vec<(String, usize)> {
7-
if !may_hold_bare_call(text.as_bytes()) {
8+
if !may_hold_bare_call(text) {
89
return Vec::new();
910
}
1011
at_constructs(text)
@@ -18,7 +19,8 @@ pub fn bare_macro_calls(text: &str) -> Vec<(String, usize)> {
1819
}
1920

2021
/// Whether an `@` followed by a name and `(` that is not a kind prefix or declaration occurs.
21-
fn may_hold_bare_call(bytes: &[u8]) -> bool {
22+
fn may_hold_bare_call(text: &str) -> bool {
23+
let bytes: &[u8] = text.as_bytes();
2224
for at in memchr::memchr_iter(b'@', bytes) {
2325
let start: usize = at + 1;
2426
let mut end: usize = start;
@@ -36,7 +38,7 @@ fn may_hold_bare_call(bytes: &[u8]) -> bool {
3638
) {
3739
continue;
3840
}
39-
if bytes.get(end) == Some(&b'(') {
41+
if opens_bare_call(text, end) {
4042
return true;
4143
}
4244
}

‎crates/sqlbuild-analysis/src/sql_macros/main/mod.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ pub mod declaration_visible_from_macro;
44
pub mod expand_sql_macro_call;
55
pub mod parse_sql_macro;
66
pub mod render_inline_collection;
7+
pub mod sql_macro_body_form;
78
pub mod sql_macro_calls;
89
pub mod sql_macro_cycle;
10+
pub mod sql_macro_parameter_spans;
911
pub mod with_list_sql_macro_calls;
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
//! The form of a SQL macro body, as its definition reads it.
2+
3+
use crate::sql_macros::_helpers::definition::body_form;
4+
use crate::sql_macros::_helpers::lexing::tokens;
5+
use crate::sql_macros::models::SqlMacroForm;
6+
7+
/// Whether `body` is a CTE list, a column list or an expression, from its first token.
8+
#[must_use]
9+
pub fn sql_macro_body_form(body: &str) -> SqlMacroForm {
10+
match tokens(body) {
11+
Ok(scanned) => body_form(body, &scanned),
12+
Err(_) => SqlMacroForm::Expression,
13+
}
14+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
//! The byte spans of a SQL macro body's `@name` parameter references, as expansion reads them.
2+
3+
use crate::sql_macros::_helpers::lexing::tokens;
4+
use crate::sql_macros::_helpers::parameters::parameter_references;
5+
6+
/// The `start..end` byte span of each `@name` in `body` that expansion replaces by an argument.
7+
#[must_use]
8+
pub fn sql_macro_parameter_spans(body: &str) -> Vec<(usize, usize)> {
9+
let Ok(scanned) = tokens(body) else {
10+
return Vec::new();
11+
};
12+
parameter_references(body, &scanned)
13+
.into_iter()
14+
.map(|reference| (reference.start, reference.end))
15+
.collect()
16+
}

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use crate::sql_macros::errors::ExpansionFailure;
55
use crate::sql_macros::errors::SqlMacroError;
66
use crate::sql_macros::main::parse_sql_macro::parse_sql_macro;
77
use crate::sql_macros::models::{SqlMacroDefinition, TextContext};
8-
use crate::sql_macros::tests::test_types::FakeHost;
8+
use crate::sql_macros::tests::test_types::{AsciiArguments, FakeHost};
99
use crate::sql_macros::types::{ConstantShape, SqlMacroHost, SqlMacroScope};
1010

1111
/// Constants and enum members the fake host renders, by reference text.
@@ -116,3 +116,13 @@ fn macro_error(failure: ExpansionFailure<String>) -> SqlMacroError {
116116
pub(super) fn owned(names: &[&str]) -> Vec<String> {
117117
names.iter().map(|name| (*name).to_owned()).collect()
118118
}
119+
120+
impl sqlbuild_render::macro_arguments::types::ArgumentHost for AsciiArguments {
121+
fn character_named(&self, _name: &str) -> Option<String> {
122+
None
123+
}
124+
125+
fn identifier(&self, _text: &str) -> Option<String> {
126+
None
127+
}
128+
}

0 commit comments

Comments
 (0)