Skip to content

Commit 0cbb9a7

Browse files
committed
fix(rules): lint plain relation slot group reads in SQL macro bodies as bare columns
A plain read of a COLUMNS group stands as one bare column, as expansion pastes it, and every reference to one RELATION parameter as a relation shares one placeholder, so plain single-slot and undeclared reads through it are in scope. Real mixed-qualification and out-of-scope findings beside them are still reported.
1 parent 7e024b5 commit 0cbb9a7

9 files changed

Lines changed: 329 additions & 29 deletions

File tree

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

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,62 @@
11
//! The byte spans of a SQL macro body's `@name` parameter references, as expansion reads them.
22
3+
use crate::sql_macros::_helpers::parsing::header::unquoted;
34
use crate::sql_macros::_helpers::parsing::lexing::{Token, TokenKind, tokens};
45
use crate::sql_macros::_helpers::parsing::parameters::parameter_references;
56
use crate::sql_macros::_helpers::parsing::slots::prefix_start;
7+
use crate::sql_macros::models::{ParameterSpan, SqlMacroParameter};
68

7-
/// The span of each `@name` expansion replaces, and of each slot name whose column a call maps.
9+
/// Each `@name` span expansion replaces: aliased and plain group reads whole, other plain reads as relation and slot.
810
#[must_use]
9-
pub fn sql_macro_parameter_spans(body: &str) -> Vec<(usize, usize)> {
11+
pub fn sql_macro_parameter_spans(
12+
body: &str,
13+
parameters: &[SqlMacroParameter],
14+
) -> Vec<ParameterSpan> {
1015
let Ok(scanned) = tokens(body) else {
1116
return Vec::new();
1217
};
13-
let mut spans: Vec<(usize, usize)> = Vec::new();
18+
let relation = |name: &str| -> Option<&SqlMacroParameter> {
19+
parameters
20+
.iter()
21+
.find(|parameter| parameter.name.eq_ignore_ascii_case(name))
22+
.filter(|parameter| parameter.kind.declares_relation())
23+
};
24+
let mut spans: Vec<ParameterSpan> = Vec::new();
1425
for reference in parameter_references(body, &scanned) {
1526
let name: usize = reference.token;
27+
let declared: Option<&SqlMacroParameter> = relation(&reference.name);
28+
let named_relation: Option<String> =
29+
declared.map(|parameter| parameter.name.to_lowercase());
1630
let Some(slot) = slot_word(body, &scanned, name) else {
17-
spans.push((reference.start, reference.end));
31+
spans.push(ParameterSpan {
32+
start: reference.start,
33+
end: reference.end,
34+
relation: named_relation,
35+
});
1836
continue;
1937
};
20-
if name >= 1 && prefix_start(body, &scanned, name - 1).is_some() {
21-
spans.push((reference.start, slot.end));
38+
let slot_name: String = unquoted(slot.text(body));
39+
let qualified: bool = name >= 1 && prefix_start(body, &scanned, name - 1).is_some();
40+
let group: bool =
41+
declared.is_some_and(|parameter| parameter.kind.declares_group(&slot_name));
42+
if qualified || group {
43+
spans.push(ParameterSpan {
44+
start: reference.start,
45+
end: slot.end,
46+
relation: None,
47+
});
2248
continue;
2349
}
24-
spans.push((reference.start, reference.end));
25-
spans.push((slot.start, slot.end));
50+
spans.push(ParameterSpan {
51+
start: reference.start,
52+
end: reference.end,
53+
relation: named_relation,
54+
});
55+
spans.push(ParameterSpan {
56+
start: slot.start,
57+
end: slot.end,
58+
relation: None,
59+
});
2660
}
2761
spans
2862
}

‎crates/sqlbuild-analysis/src/sql_macros/models.rs‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,11 @@ impl ParameterKind {
7777
groups.iter().any(|group| group.eq_ignore_ascii_case(slot))
7878
}
7979

80+
/// Whether this is a `RELATION`.
81+
pub fn declares_relation(&self) -> bool {
82+
matches!(self, Self::Relation { .. })
83+
}
84+
8085
/// Whether this is a `RELATION` that declares a slot called `slot`.
8186
pub fn declares_slot(&self, slot: &str) -> bool {
8287
let Self::Relation { columns, groups } = self else {
@@ -468,6 +473,15 @@ pub struct SqlMacroFinding {
468473
pub declared_at: SqlMacroLocation,
469474
}
470475

476+
/// The byte span of one parameter reference a SQL macro body's lint replaces.
477+
#[derive(Clone, Debug, PartialEq, Eq)]
478+
pub struct ParameterSpan {
479+
pub start: usize,
480+
pub end: usize,
481+
/// The `RELATION` parameter this span names as a relation, as `@weekly` in `FROM @weekly`.
482+
pub relation: Option<String>,
483+
}
484+
471485
/// What type inference knows of an expression macro body over its declared arguments.
472486
#[derive(Clone, Debug)]
473487
pub(crate) enum BodyType {

‎crates/sqlbuild-python/src/bindings/_helpers/rules/lint_pipeline.rs‎

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@ use std::sync::atomic::{AtomicBool, Ordering};
88
use pyo3::exceptions::{PyException, PyValueError};
99
use pyo3::prelude::{Bound, IntoPyObject, PyAny, PyAnyMethods, PyErr, PyResult, Python};
1010
use pyo3::types::{PyDict, PyDictMethods, PyString, PyTuple};
11+
use sqlbuild_analysis::sql_macros::main::parse_sql_macro::parse_sql_macro;
1112
use sqlbuild_analysis::sql_macros::main::sql_macro_body_form::sql_macro_body_form;
12-
use sqlbuild_analysis::sql_macros::models::SqlMacroForm;
13+
use sqlbuild_analysis::sql_macros::models::{SqlMacroForm, SqlMacroParameter};
1314
use sqlbuild_core::panics::main::catch_compiler_panic::catch_compiler_panic;
1415
use sqlbuild_core::text::main::python_isspace::python_isspace;
1516
use sqlbuild_core::text::main::python_str_repr::python_str_repr;
@@ -755,9 +756,13 @@ fn plan_bodies(
755756
ranges = sql_body_ranges(text, headers);
756757
}
757758
let sql_test: bool = headers.iter().any(|header| header.kind == SQL_TEST_KIND);
758-
let neutralize: Option<fn(&str) -> Neutralized> = if sql_macro {
759-
Some(neutralize_sql_macro_parameters)
760-
} else if sql_test {
759+
let parameters: Vec<SqlMacroParameter> = if sql_macro {
760+
sql_macro_parameters(file)
761+
} else {
762+
Vec::new()
763+
};
764+
let neutralize_macro = |body: &str| neutralize_sql_macro_parameters(body, &parameters);
765+
let other: Option<fn(&str) -> Neutralized> = if sql_test {
761766
Some(neutralize_test_parameters)
762767
} else {
763768
neutralizer(&BodyRole {
@@ -766,6 +771,13 @@ fn plan_bodies(
766771
in_hook_directory,
767772
})
768773
};
774+
let neutralize: Option<&dyn Fn(&str) -> Neutralized> = if sql_macro {
775+
Some(&neutralize_macro)
776+
} else {
777+
other
778+
.as_ref()
779+
.map(|function| function as &dyn Fn(&str) -> Neutralized)
780+
};
769781
let mut bodies: Vec<PlannedBody> = ranges
770782
.into_iter()
771783
.map(|range| plan_body(text, range, neutralize, compiled))
@@ -838,7 +850,15 @@ pub(crate) fn prepare_single_body(request: &SingleBodyRequest<'_, '_>) -> PyResu
838850
let dialect: &str = dialect;
839851
let py: Python<'_> = file_path.py();
840852
let text: Vec<char> = contents.chars().collect();
841-
let planned: PlannedBody = plan_body(&text, range, neutralizer(role), None);
853+
let neutralize: Option<fn(&str) -> Neutralized> = neutralizer(role);
854+
let planned: PlannedBody = plan_body(
855+
&text,
856+
range,
857+
neutralize
858+
.as_ref()
859+
.map(|function| function as &dyn Fn(&str) -> Neutralized),
860+
None,
861+
);
842862
let (expanded, passes) = match planned.source {
843863
BodySource::Expand => {
844864
let expansion: AuthoredExpansion =
@@ -895,10 +915,22 @@ fn not_prepared(py: Python<'_>, path: &Path) -> PyResult<PyErr> {
895915
))
896916
}
897917

918+
/// The declared parameters of a SQL macro file, or none when its header does not parse.
919+
fn sql_macro_parameters(file: &LintFile) -> Vec<SqlMacroParameter> {
920+
let name: String = file
921+
.path
922+
.file_stem()
923+
.map(|stem| stem.to_string_lossy().into_owned())
924+
.unwrap_or_default();
925+
parse_sql_macro(&name, &file.path.to_string_lossy(), &file.contents)
926+
.map(|definition| definition.parameters)
927+
.unwrap_or_default()
928+
}
929+
898930
fn plan_body(
899931
text: &[char],
900932
(mut start, end): (usize, usize),
901-
neutralize: Option<fn(&str) -> Neutralized>,
933+
neutralize: Option<&dyn Fn(&str) -> Neutralized>,
902934
compiled: Option<&CompiledExpansion>,
903935
) -> PlannedBody {
904936
let body: String = text[start..end].iter().collect();

‎crates/sqlbuild-rules/src/lint_text/main/neutralize_sql_macro_parameters.rs‎

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,34 @@
11
//! `neutralize_sql_macro_parameters`: a SQL macro body's `@name` parameters replaced by sentinels.
22
3+
use std::collections::HashMap;
4+
35
use sqlbuild_analysis::sql_macros::main::sql_macro_parameter_spans::sql_macro_parameter_spans;
6+
use sqlbuild_analysis::sql_macros::models::{ParameterSpan, SqlMacroParameter};
47

58
use crate::lint_text::_helpers::sites::{ByteSite, assemble, sentinel};
69
use crate::lint_text::constants::SQL_MACRO_PARAMETER_SENTINEL_PREFIX;
710
use crate::lint_text::models::Neutralized;
811

9-
/// Replace each parameter reference expansion would replace, and nothing else, with a sentinel.
10-
pub fn neutralize_sql_macro_parameters(body: &str) -> Neutralized {
11-
let byte_sites: Vec<ByteSite> = sql_macro_parameter_spans(body)
12+
/// Replace each parameter reference with a sentinel; one `RELATION` parameter's relation spans share one.
13+
pub fn neutralize_sql_macro_parameters(
14+
body: &str,
15+
parameters: &[SqlMacroParameter],
16+
) -> Neutralized {
17+
let mut relations: HashMap<String, String> = HashMap::new();
18+
let mut next: usize = 0;
19+
let mut fresh = || {
20+
let made: String = sentinel(SQL_MACRO_PARAMETER_SENTINEL_PREFIX, next);
21+
next += 1;
22+
made
23+
};
24+
let byte_sites: Vec<ByteSite> = sql_macro_parameter_spans(body, parameters)
1225
.into_iter()
13-
.enumerate()
14-
.map(|(index, (start, end))| {
15-
(
16-
start,
17-
end,
18-
sentinel(SQL_MACRO_PARAMETER_SENTINEL_PREFIX, index),
19-
)
26+
.map(|span: ParameterSpan| {
27+
let replacement: String = match span.relation {
28+
Some(relation) => relations.entry(relation).or_insert_with(&mut fresh).clone(),
29+
None => fresh(),
30+
};
31+
(span.start, span.end, replacement)
2032
})
2133
.collect();
2234
assemble(body, byte_sites)

‎crates/sqlbuild-rules/src/lint_text/tests/test_lint_text.rs‎

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,10 @@ use crate::lint_text::models::{ExpansionSpan, FormatFile, LintSqlBody, LintSqlOp
1717
use crate::lint_text::tests::helpers::directive_finding;
1818
use crate::lint_text::tests::test_types::{
1919
Finding, FormatBodiesTestCase, LintBodiesTestCase, NeutralizeTestCase, ParameterTestCase,
20-
RestoreTestCase, SitePatternTestCase, SuppressionTestCase, TokenAtTestCase,
20+
RestoreTestCase, SitePatternTestCase, SlotReadTestCase, SuppressionTestCase, TokenAtTestCase,
2121
UnwrappedExpressionTestCase,
2222
};
23+
use sqlbuild_analysis::sql_macros::main::parse_sql_macro::parse_sql_macro;
2324

2425
const SUPPRESSED_FILE: &str =
2526
"-- sqb: ignore SQBRSQL005 because kept\n\n-- note\nselect 1\n-- sqb: ignore nope\nselect 2\n";
@@ -144,25 +145,25 @@ fn given_generated_parameters_when_neutralizing_then_only_bare_parameters_are_re
144145
ParameterTestCase {
145146
description: "SQL macro parameters followed by words and a parenthesis",
146147
body: "@value IS NULL OR TRIM(@value) = ''",
147-
neutralize: neutralize_sql_macro_parameters,
148+
neutralize: |body| neutralize_sql_macro_parameters(body, &[]),
148149
expected_text: "__sqlbuild_macro_parameter_0__ IS NULL OR TRIM(__sqlbuild_macro_parameter_1__) = ''",
149150
},
150151
ParameterTestCase {
151152
description: "SQL macro parameters before IN and a parenthesised predicate",
152153
body: "CASE WHEN @x IN ('a', 'b') THEN 1 END + CASE WHEN @x IS NULL AND (@y IS NULL) THEN 1 END",
153-
neutralize: neutralize_sql_macro_parameters,
154+
neutralize: |body| neutralize_sql_macro_parameters(body, &[]),
154155
expected_text: "CASE WHEN __sqlbuild_macro_parameter_0__ IN ('a', 'b') THEN 1 END + CASE WHEN __sqlbuild_macro_parameter_1__ IS NULL AND (__sqlbuild_macro_parameter_2__ IS NULL) THEN 1 END",
155156
},
156157
ParameterTestCase {
157158
description: "SQL macro slot reads hide the slot name, whole after an alias",
158159
body: "SELECT s.@src.amount AS amount, @src.id, @src.* FROM @src AS s",
159-
neutralize: neutralize_sql_macro_parameters,
160+
neutralize: |body| neutralize_sql_macro_parameters(body, &[]),
160161
expected_text: "SELECT s.__sqlbuild_macro_parameter_0__ AS amount, __sqlbuild_macro_parameter_1__.__sqlbuild_macro_parameter_2__, __sqlbuild_macro_parameter_3__.* FROM __sqlbuild_macro_parameter_4__ AS s",
161162
},
162163
ParameterTestCase {
163164
description: "SQL macro calls, enum references and quoted text keep their at-signs",
164165
body: "@sql.other(@v) + @enum(\"s\").A + LENGTH('@v') + @@x",
165-
neutralize: neutralize_sql_macro_parameters,
166+
neutralize: |body| neutralize_sql_macro_parameters(body, &[]),
166167
expected_text: "@sql.other(__sqlbuild_macro_parameter_0__) + @enum(\"s\").A + LENGTH('@v') + @@x",
167168
},
168169
ParameterTestCase {
@@ -190,6 +191,43 @@ fn given_generated_parameters_when_neutralizing_then_only_bare_parameters_are_re
190191
}
191192
}
192193

194+
#[test]
195+
fn given_relation_slot_reads_when_neutralizing_then_they_stand_as_their_expansion_does() {
196+
let header: &str = "MACRO (\n description \"Weekly rates\",\n \
197+
arguments (weekly RELATION (keys COLUMNS, refund_count BIGINT), cutoff BIGINT),\n);\n\n";
198+
let test_cases = [
199+
SlotReadTestCase {
200+
description: "a plain group read is one bare value",
201+
body: "SELECT @weekly.keys, orders FROM counted GROUP BY @weekly.keys",
202+
expected_text: "SELECT __sqlbuild_macro_parameter_0__, orders FROM counted GROUP BY __sqlbuild_macro_parameter_1__",
203+
},
204+
SlotReadTestCase {
205+
description: "plain single-slot and undeclared reads go through the one relation",
206+
body: "SELECT @weekly.refund_count, @weekly.week FROM @weekly WHERE @cutoff > @cutoff",
207+
expected_text: "SELECT __sqlbuild_macro_parameter_0__.__sqlbuild_macro_parameter_1__, __sqlbuild_macro_parameter_0__.__sqlbuild_macro_parameter_2__ FROM __sqlbuild_macro_parameter_0__ WHERE __sqlbuild_macro_parameter_3__ > __sqlbuild_macro_parameter_4__",
208+
},
209+
SlotReadTestCase {
210+
description: "aliased reads stay whole after their alias",
211+
body: "SELECT s.@weekly.keys, s.@weekly.refund_count FROM @weekly AS s",
212+
expected_text: "SELECT s.__sqlbuild_macro_parameter_0__, s.__sqlbuild_macro_parameter_1__ FROM __sqlbuild_macro_parameter_2__ AS s",
213+
},
214+
];
215+
216+
for test_case in test_cases {
217+
let source: String = format!("{header}{}\n", test_case.body);
218+
let parameters = parse_sql_macro("weekly_rates", "macros/sql/weekly_rates.sql", &source)
219+
.map(|definition| definition.parameters)
220+
.unwrap_or_default();
221+
222+
assert_eq!(
223+
neutralize_sql_macro_parameters(test_case.body, &parameters).text,
224+
test_case.expected_text,
225+
"{}",
226+
test_case.description
227+
);
228+
}
229+
}
230+
193231
#[test]
194232
fn given_formatted_sql_when_restoring_then_each_sentinel_must_appear_once()
195233
-> Result<(), LintTextError> {

‎crates/sqlbuild-rules/src/lint_text/tests/test_types.rs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ pub(crate) struct NeutralizeTestCase {
88
pub(crate) expected_sites: Vec<(&'static str, usize, usize)>,
99
}
1010

11+
pub(crate) struct SlotReadTestCase {
12+
pub(crate) description: &'static str,
13+
pub(crate) body: &'static str,
14+
pub(crate) expected_text: &'static str,
15+
}
16+
1117
pub(crate) struct ParameterTestCase {
1218
pub(crate) description: &'static str,
1319
pub(crate) body: &'static str,

‎tests/e2e/src/sqlbuild/cli/commands/main/compile/sql_macros/_test_types.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,3 +125,14 @@ class SqlMacroFormatPairTestCase:
125125
slot_text: str
126126
hand_text: str
127127
expected_check_returncode: int
128+
129+
130+
@dataclass(frozen=True)
131+
class SqlMacroBodyRulesTestCase:
132+
"""One project linting a SQL macro body, and the findings it must and must not print."""
133+
134+
description: str
135+
files: dict[str, str]
136+
expected_returncode: int
137+
expected_fragments: tuple[str, ...]
138+
expected_absent_fragments: tuple[str, ...]

‎tests/e2e/src/sqlbuild/cli/commands/main/compile/sql_macros/helpers.py‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -554,3 +554,43 @@ def scoped_model(macros: dict[str, str], body: str) -> dict[str, str]:
554554
f"{body}\n"
555555
),
556556
}
557+
558+
559+
SQL_RULES: dict[str, str] = {
560+
"sqlbuild_project.toml": BASE_FILES["sqlbuild_project.toml"]
561+
+ '\n[rules]\nselect = ["SQBRSQL028", "SQBRSQL029"]\n'
562+
}
563+
564+
565+
def rates_with_middle_cte(middle: str) -> dict[str, str]:
566+
"""The docs' weekly refund rates macro with `middle` as a CTE between its two, and a model."""
567+
568+
return {
569+
"macros/sql/weekly_rates.sql": (
570+
'MACRO (\n description "Weekly refund rate per grain",\n'
571+
" arguments (weekly RELATION (keys COLUMNS, order_count BIGINT, refund_count BIGINT)),"
572+
"\n);\n\n"
573+
"WITH counted AS (\n"
574+
" SELECT\n"
575+
" s.@weekly.keys,\n"
576+
" s.@weekly.order_count AS orders,\n"
577+
" s.@weekly.refund_count AS refunds\n"
578+
" FROM @weekly AS s\n"
579+
"),\n\n"
580+
f"{middle},\n\n"
581+
"rated AS (\n"
582+
" SELECT\n"
583+
" @weekly.keys,\n"
584+
" SUM(refunds) / NULLIF(SUM(orders), 0) AS refund_rate\n"
585+
" FROM plain\n"
586+
" GROUP BY @weekly.keys\n"
587+
")\n"
588+
),
589+
"models/marts/rates.sql": (
590+
'MODEL (description "Refund rates");\n\n'
591+
'WITH weekly AS (SELECT * FROM __seed("weekly_orders")),\n'
592+
"@sql.weekly_rates(weekly, weekly.keys := (product_id, region), "
593+
"weekly.order_count := orders_placed)\n"
594+
"SELECT * FROM rated\n"
595+
),
596+
}

0 commit comments

Comments
 (0)