Skip to content

Commit 229570f

Browse files
authored
feat: check enum literals at compile time and fix rule helper and slot checks (#1258)
New K013 compile error: a constant string literal that fills an enum-typed column of an enforced contract must be an enum member (direct, in UNION branches, or through unfiltered CTE and derived-table passthroughs; skipped on SQL Server). Rules can read enum types with ctx.columns.enum_types. The rule test harness loads project rule helpers. Slot checks cover LATERAL subqueries and relation-qualified reads beside groups.
1 parent cdbd85b commit 229570f

51 files changed

Lines changed: 2084 additions & 21 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 377 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,377 @@
1+
//! Constant string literals that reach an output column, and where the authored file writes them.
2+
3+
use polyglot_sql::expressions::{Cte, Literal, Select, With};
4+
use polyglot_sql::tokens::{Token, TokenType};
5+
use polyglot_sql::{Dialect, DialectType, Expression};
6+
7+
use crate::contracts::models::LiteralLocation;
8+
9+
/// How deep the walk follows set operations and passthrough columns before it stops.
10+
const MAX_DEPTH: usize = 64;
11+
12+
/// The output column a walk follows: by name, or by position in a set-operation branch.
13+
#[derive(Clone, Copy)]
14+
enum Target<'n> {
15+
Name(&'n str),
16+
Position(usize),
17+
}
18+
19+
/// The CTEs a query can read, in definition order, and how deep the walk has gone.
20+
#[derive(Clone)]
21+
struct Scope<'a> {
22+
ctes: Vec<&'a Cte>,
23+
depth: usize,
24+
}
25+
26+
impl<'a> Scope<'a> {
27+
/// The scope one step deeper, with `with`'s CTEs visible unless they are recursive.
28+
fn within(&self, with: Option<&'a With>) -> Self {
29+
let mut ctes: Vec<&'a Cte> = self.ctes.clone();
30+
if let Some(with) = with
31+
&& !with.recursive
32+
{
33+
ctes.extend(with.ctes.iter());
34+
}
35+
Self {
36+
ctes,
37+
depth: self.depth + 1,
38+
}
39+
}
40+
41+
/// The scope a CTE body sees: the CTEs defined before it.
42+
fn before(&self, index: usize) -> Self {
43+
Self {
44+
ctes: self.ctes[..index].to_vec(),
45+
depth: self.depth + 1,
46+
}
47+
}
48+
}
49+
50+
/// The literals found so far.
51+
#[derive(Default)]
52+
struct LiteralWalk {
53+
found: Vec<String>,
54+
}
55+
56+
/// Every constant string literal that fills output column `column` of `query`, in query order.
57+
pub(crate) fn output_literals(query: &Expression, column: &str) -> Vec<String> {
58+
let mut walk: LiteralWalk = LiteralWalk::default();
59+
let scope: Scope<'_> = Scope {
60+
ctes: Vec::new(),
61+
depth: 0,
62+
};
63+
walk.query(query, Target::Name(column), &scope);
64+
walk.found
65+
}
66+
67+
/// The parsed statement of `sql` under `dialect`, when it is one query.
68+
pub(crate) fn parsed_query(sql: &str, dialect: &str) -> Option<Expression> {
69+
let Ok(dialect) = dialect.parse::<DialectType>() else {
70+
return None;
71+
};
72+
let Ok(mut statements) = polyglot_sql::parse(sql, dialect) else {
73+
return None;
74+
};
75+
(statements.len() == 1).then(|| statements.pop()).flatten()
76+
}
77+
78+
/// The first string literal with text `value` in `query`, located in `authored`, the whole file.
79+
pub(crate) fn literal_location(
80+
(authored, query): (&str, &str),
81+
value: &str,
82+
dialect: &str,
83+
) -> Option<LiteralLocation> {
84+
let dialect: DialectType = dialect
85+
.parse::<DialectType>()
86+
.unwrap_or(DialectType::Generic);
87+
let Ok(tokens) = Dialect::get(dialect).tokenize(query) else {
88+
return None;
89+
};
90+
let matches = |index: &usize| {
91+
tokens[*index].token_type == TokenType::String && tokens[*index].text == value
92+
};
93+
let index: usize = (0..tokens.len())
94+
.filter(matches)
95+
.find(|index| select_item(&tokens, *index))
96+
.or_else(|| (0..tokens.len()).find(matches))?;
97+
let token = &tokens[index];
98+
let query_start: usize = authored.find(query)?;
99+
let offset: usize = authored[..query_start].chars().count();
100+
let (line, column) = line_column(authored, offset + token.span.start);
101+
let (end_line, end_column) = line_column(authored, offset + token.span.end);
102+
Some(LiteralLocation {
103+
line,
104+
column,
105+
end_line,
106+
end_column,
107+
})
108+
}
109+
110+
/// Whether the token at `index` stands alone as a select-list item, as a projected literal does.
111+
fn select_item(tokens: &[Token], index: usize) -> bool {
112+
let opens: bool = index.checked_sub(1).is_some_and(|previous| {
113+
matches!(
114+
tokens[previous].token_type,
115+
TokenType::Select | TokenType::Distinct | TokenType::All | TokenType::Comma
116+
)
117+
});
118+
let closes: bool = tokens.get(index + 1).is_none_or(|next| {
119+
matches!(
120+
next.token_type,
121+
TokenType::As
122+
| TokenType::Comma
123+
| TokenType::From
124+
| TokenType::Union
125+
| TokenType::Semicolon
126+
| TokenType::RParen
127+
| TokenType::Identifier
128+
| TokenType::QuotedIdentifier
129+
| TokenType::Var
130+
)
131+
});
132+
opens && closes
133+
}
134+
135+
/// The one-based line and column of character `offset` in `text`.
136+
fn line_column(text: &str, offset: usize) -> (usize, usize) {
137+
let mut line: usize = 1;
138+
let mut column: usize = 1;
139+
for character in text.chars().take(offset) {
140+
if character == '\n' {
141+
line += 1;
142+
column = 1;
143+
} else {
144+
column += 1;
145+
}
146+
}
147+
(line, column)
148+
}
149+
150+
/// The position of output column `name` in the leftmost branch of `query`, before any star.
151+
fn output_position(query: &Expression, name: &str, depth: usize) -> Option<usize> {
152+
if depth > MAX_DEPTH {
153+
return None;
154+
}
155+
match query {
156+
Expression::Select(select) => {
157+
for (position, projection) in select.expressions.iter().enumerate() {
158+
match projection {
159+
Expression::Star(_) => return None,
160+
projection
161+
if output_name(projection).is_some_and(|known| same(known, name)) =>
162+
{
163+
return Some(position);
164+
}
165+
_ => {}
166+
}
167+
}
168+
None
169+
}
170+
Expression::Union(union) => output_position(&union.left, name, depth + 1),
171+
Expression::Intersect(intersect) => output_position(&intersect.left, name, depth + 1),
172+
Expression::Subquery(subquery) if subquery.column_aliases.is_empty() => {
173+
output_position(&subquery.this, name, depth + 1)
174+
}
175+
Expression::Paren(paren) => output_position(&paren.this, name, depth + 1),
176+
_ => None,
177+
}
178+
}
179+
180+
/// The name a projection outputs, when it names one.
181+
fn output_name(projection: &Expression) -> Option<&str> {
182+
match projection {
183+
Expression::Alias(alias) => Some(alias.alias.name.as_str()),
184+
Expression::Column(column) => Some(column.name.name.as_str()),
185+
_ => None,
186+
}
187+
}
188+
189+
fn same(left: &str, right: &str) -> bool {
190+
left.eq_ignore_ascii_case(right)
191+
}
192+
193+
/// The one `FROM` item a select passes every row of through, when `qualifier` names it or is absent.
194+
fn passthrough_source<'a>(select: &'a Select, qualifier: Option<&str>) -> Option<&'a Expression> {
195+
if !keeps_every_row(select) {
196+
return None;
197+
}
198+
let [source] = select.from.as_ref()?.expressions.as_slice() else {
199+
return None;
200+
};
201+
match qualifier {
202+
Some(qualifier) if !source_alias(source).is_some_and(|alias| same(alias, qualifier)) => {
203+
None
204+
}
205+
_ => Some(source),
206+
}
207+
}
208+
209+
/// Whether a select outputs a row for every row it reads: no filter, join, limit or sampling.
210+
fn keeps_every_row(select: &Select) -> bool {
211+
keeps_any_row(select)
212+
&& select.joins.is_empty()
213+
&& select.lateral_views.is_empty()
214+
&& select.prewhere.is_none()
215+
&& select.where_clause.is_none()
216+
&& select.having.is_none()
217+
&& select.qualify.is_none()
218+
&& select.distinct_on.is_none()
219+
&& select.connect.is_none()
220+
}
221+
222+
/// Whether a select has no row limit or sampling, which could leave a literal with no rows.
223+
fn keeps_any_row(select: &Select) -> bool {
224+
select.limit.is_none()
225+
&& select.offset.is_none()
226+
&& select.limit_by.is_none()
227+
&& select.fetch.is_none()
228+
&& select.top.is_none()
229+
&& select.sample.is_none()
230+
}
231+
232+
/// The name a `FROM` item is read by: its alias, or a table's own name.
233+
fn source_alias(source: &Expression) -> Option<&str> {
234+
match source {
235+
Expression::Table(table) => Some(
236+
table
237+
.alias
238+
.as_ref()
239+
.map_or(table.name.name.as_str(), |alias| alias.name.as_str()),
240+
),
241+
Expression::Subquery(subquery) => subquery.alias.as_ref().map(|alias| alias.name.as_str()),
242+
_ => None,
243+
}
244+
}
245+
246+
impl LiteralWalk {
247+
fn query<'a>(&mut self, query: &'a Expression, target: Target<'_>, scope: &Scope<'a>) {
248+
if scope.depth > MAX_DEPTH {
249+
return;
250+
}
251+
match query {
252+
Expression::Select(select) => {
253+
self.select(select, target, &scope.within(select.with.as_ref()))
254+
}
255+
Expression::Union(union) if union.limit.is_none() && union.offset.is_none() => {
256+
let scope: Scope<'a> = scope.within(union.with.as_ref());
257+
let target: Option<Target<'_>> = match target {
258+
Target::Name(_) if union.by_name => Some(target),
259+
Target::Position(_) if union.by_name => None,
260+
Target::Name(name) => {
261+
output_position(&union.left, name, 0).map(Target::Position)
262+
}
263+
Target::Position(_) => Some(target),
264+
};
265+
if let Some(target) = target {
266+
self.query(&union.left, target, &scope);
267+
self.query(&union.right, target, &scope);
268+
}
269+
}
270+
Expression::Subquery(subquery)
271+
if subquery.column_aliases.is_empty()
272+
&& subquery.limit.is_none()
273+
&& subquery.offset.is_none() =>
274+
{
275+
self.query(&subquery.this, target, &scope.within(None));
276+
}
277+
Expression::Paren(paren) => self.query(&paren.this, target, &scope.within(None)),
278+
_ => {}
279+
}
280+
}
281+
282+
fn select<'a>(&mut self, select: &'a Select, target: Target<'_>, scope: &Scope<'a>) {
283+
if !keeps_any_row(select) {
284+
return;
285+
}
286+
match target {
287+
Target::Position(position) => {
288+
let starred: bool = select
289+
.expressions
290+
.iter()
291+
.take(position + 1)
292+
.any(|projection| matches!(projection, Expression::Star(_)));
293+
if let Some(projection) = select.expressions.get(position)
294+
&& !starred
295+
{
296+
self.expression(projection, select, scope);
297+
}
298+
}
299+
Target::Name(name) => {
300+
let named: Vec<&Expression> = select
301+
.expressions
302+
.iter()
303+
.filter(|projection| {
304+
output_name(projection).is_some_and(|known| same(known, name))
305+
})
306+
.collect();
307+
if let [projection] = named.as_slice() {
308+
self.expression(projection, select, scope);
309+
return;
310+
}
311+
if !named.is_empty() {
312+
return;
313+
}
314+
let [Expression::Star(star)] = select.expressions.as_slice() else {
315+
return;
316+
};
317+
if star.except.is_some() || star.replace.is_some() || star.rename.is_some() {
318+
return;
319+
}
320+
let qualifier: Option<&str> = star.table.as_ref().map(|table| table.name.as_str());
321+
if let Some(source) = passthrough_source(select, qualifier) {
322+
self.source(source, name, scope);
323+
}
324+
}
325+
}
326+
}
327+
328+
fn expression<'a>(
329+
&mut self,
330+
expression: &'a Expression,
331+
select: &'a Select,
332+
scope: &Scope<'a>,
333+
) {
334+
match expression {
335+
Expression::Alias(alias) => self.expression(&alias.this, select, scope),
336+
Expression::Paren(paren) => self.expression(&paren.this, select, scope),
337+
Expression::Literal(literal) => {
338+
if let Literal::String(value) = literal.as_ref() {
339+
self.found.push(value.clone());
340+
}
341+
}
342+
Expression::Column(column) => {
343+
let qualifier: Option<&str> =
344+
column.table.as_ref().map(|table| table.name.as_str());
345+
if let Some(source) = passthrough_source(select, qualifier) {
346+
self.source(source, &column.name.name, scope);
347+
}
348+
}
349+
_ => {}
350+
}
351+
}
352+
353+
/// The literals filling column `name` of a CTE or derived table that a `FROM` item reads.
354+
fn source<'a>(&mut self, source: &'a Expression, name: &str, scope: &Scope<'a>) {
355+
match source {
356+
Expression::Table(table) if table.schema.is_none() && table.catalog.is_none() => {
357+
let Some(index) = scope
358+
.ctes
359+
.iter()
360+
.rposition(|cte| same(&cte.alias.name, &table.name.name))
361+
else {
362+
return;
363+
};
364+
let cte: &Cte = scope.ctes[index];
365+
if table.column_aliases.is_empty() && cte.columns.is_empty() {
366+
self.query(&cte.this, Target::Name(name), &scope.before(index));
367+
}
368+
}
369+
Expression::Subquery(subquery)
370+
if subquery.column_aliases.is_empty() && !subquery.lateral =>
371+
{
372+
self.query(&subquery.this, Target::Name(name), &scope.within(None));
373+
}
374+
_ => {}
375+
}
376+
}
377+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
pub(crate) mod columns;
22
pub(crate) mod dynamic;
3+
pub(crate) mod enum_literals;
34
pub(crate) mod promotion;
45
pub(crate) mod setting_help;
56
pub(crate) mod type_comparison;

‎crates/sqlbuild-analysis/src/contracts/constants.rs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ pub(crate) const EXTRA_COLUMN_CODE: &str = "K005";
88
pub(crate) const MISSING_DECLARATIONS_CODE: &str = "K006";
99
pub(crate) const DYNAMIC_OUTPUT_NOT_PROVEN_CODE: &str = "K011";
1010
pub(crate) const PROMOTION_CONFLICT_CODE: &str = "K011";
11+
pub(crate) const ENUM_LITERAL_CODE: &str = "K013";
12+
/// Dialects whose default string comparison ignores case or trailing spaces, so K013 is skipped.
13+
pub(crate) const CASE_INSENSITIVE_DIALECTS: [&str; 1] = ["tsql"];
1114

1215
pub const CONTRACT_ENFORCED: &str = "enforced";
1316
pub(crate) const CONTRACT_NONE: &str = "none";

0 commit comments

Comments
 (0)