Skip to main content

sqrust_rules/structure/
zero_limit_clause.rs

1use sqrust_core::{Diagnostic, FileContext, Rule};
2use sqlparser::ast::{Expr, Query, Select, SelectItem, SetExpr, Statement, TableFactor, Value};
3
4use crate::capitalisation::{is_word_char, SkipMap};
5
6pub struct ZeroLimitClause;
7
8impl Rule for ZeroLimitClause {
9    fn name(&self) -> &'static str {
10        "Structure/ZeroLimitClause"
11    }
12
13    fn check(&self, ctx: &FileContext) -> Vec<Diagnostic> {
14        if !ctx.parse_errors.is_empty() {
15            return Vec::new();
16        }
17
18        let mut diags = Vec::new();
19        // Track how many LIMIT keywords we have already reported, so that
20        // when multiple queries each have LIMIT 0 we can find the Nth occurrence
21        // in source text.
22        let mut limit_counter: usize = 0;
23
24        for stmt in &ctx.statements {
25            if let Statement::Query(query) = stmt {
26                check_query(query, ctx, &mut limit_counter, &mut diags);
27            }
28        }
29
30        diags
31    }
32}
33
34// ── AST walking ───────────────────────────────────────────────────────────────
35
36fn check_query(
37    query: &Query,
38    ctx: &FileContext,
39    limit_counter: &mut usize,
40    diags: &mut Vec<Diagnostic>,
41) {
42    // Visit CTEs first.
43    if let Some(with) = &query.with {
44        for cte in &with.cte_tables {
45            check_query(&cte.query, ctx, limit_counter, diags);
46        }
47    }
48
49    // Check this query's own LIMIT.
50    if let Some(limit_expr) = &query.limit {
51        if is_zero_literal(limit_expr) {
52            let occurrence = *limit_counter;
53            *limit_counter += 1;
54            let (line, col) = find_nth_keyword_pos(&ctx.source, "LIMIT", occurrence);
55            diags.push(Diagnostic {
56                rule: "Structure/ZeroLimitClause",
57                message: "LIMIT 0 always returns an empty result set".to_string(),
58                line,
59                col,
60            });
61        }
62    }
63
64    // Recurse into the body (handles nested set operations and subqueries in
65    // FROM, WHERE, and SELECT).
66    check_set_expr(&query.body, ctx, limit_counter, diags);
67}
68
69fn check_set_expr(
70    expr: &SetExpr,
71    ctx: &FileContext,
72    limit_counter: &mut usize,
73    diags: &mut Vec<Diagnostic>,
74) {
75    match expr {
76        SetExpr::Select(sel) => {
77            check_select(sel, ctx, limit_counter, diags);
78        }
79        SetExpr::Query(inner) => {
80            check_query(inner, ctx, limit_counter, diags);
81        }
82        SetExpr::SetOperation { left, right, .. } => {
83            check_set_expr(left, ctx, limit_counter, diags);
84            check_set_expr(right, ctx, limit_counter, diags);
85        }
86        _ => {}
87    }
88}
89
90fn check_select(
91    select: &Select,
92    ctx: &FileContext,
93    limit_counter: &mut usize,
94    diags: &mut Vec<Diagnostic>,
95) {
96    // FROM clause — check Derived (subquery) tables.
97    for table_with_joins in &select.from {
98        check_table_factor(&table_with_joins.relation, ctx, limit_counter, diags);
99        for join in &table_with_joins.joins {
100            check_table_factor(&join.relation, ctx, limit_counter, diags);
101        }
102    }
103
104    // WHERE clause — check scalar subqueries.
105    if let Some(selection) = &select.selection {
106        check_expr_for_subqueries(selection, ctx, limit_counter, diags);
107    }
108
109    // SELECT projection — check scalar subqueries.
110    for item in &select.projection {
111        if let SelectItem::UnnamedExpr(e) | SelectItem::ExprWithAlias { expr: e, .. } = item {
112            check_expr_for_subqueries(e, ctx, limit_counter, diags);
113        }
114    }
115}
116
117fn check_table_factor(
118    factor: &TableFactor,
119    ctx: &FileContext,
120    limit_counter: &mut usize,
121    diags: &mut Vec<Diagnostic>,
122) {
123    if let TableFactor::Derived { subquery, .. } = factor {
124        check_query(subquery, ctx, limit_counter, diags);
125    }
126}
127
128fn check_expr_for_subqueries(
129    expr: &Expr,
130    ctx: &FileContext,
131    limit_counter: &mut usize,
132    diags: &mut Vec<Diagnostic>,
133) {
134    match expr {
135        Expr::Subquery(q) => check_query(q, ctx, limit_counter, diags),
136        Expr::InSubquery { subquery, .. } => check_query(subquery, ctx, limit_counter, diags),
137        Expr::Exists { subquery, .. } => check_query(subquery, ctx, limit_counter, diags),
138        Expr::BinaryOp { left, right, .. } => {
139            check_expr_for_subqueries(left, ctx, limit_counter, diags);
140            check_expr_for_subqueries(right, ctx, limit_counter, diags);
141        }
142        _ => {}
143    }
144}
145
146// ── Helpers ───────────────────────────────────────────────────────────────────
147
148/// Returns `true` when `expr` is the integer literal `0` (exactly).
149fn is_zero_literal(expr: &Expr) -> bool {
150    if let Expr::Value(Value::Number(s, _)) = expr {
151        s == "0"
152    } else {
153        false
154    }
155}
156
157/// Finds the `nth` (0-indexed) occurrence of `keyword` (case-insensitive,
158/// word-boundary, outside strings/comments) in `source`. Returns a 1-indexed
159/// (line, col) pair. Falls back to (1, 1) if not found.
160fn find_nth_keyword_pos(source: &str, keyword: &str, nth: usize) -> (usize, usize) {
161    let bytes = source.as_bytes();
162    let len = bytes.len();
163    let skip_map = SkipMap::build(source);
164    let kw_upper: Vec<u8> = keyword.bytes().map(|b| b.to_ascii_uppercase()).collect();
165    let kw_len = kw_upper.len();
166
167    let mut count = 0usize;
168    let mut i = 0usize;
169
170    while i + kw_len <= len {
171        if !skip_map.is_code(i) {
172            i += 1;
173            continue;
174        }
175
176        let before_ok = i == 0 || !is_word_char(bytes[i - 1]);
177        if !before_ok {
178            i += 1;
179            continue;
180        }
181
182        let matches = bytes[i..i + kw_len]
183            .iter()
184            .zip(kw_upper.iter())
185            .all(|(a, b)| a.eq_ignore_ascii_case(b));
186
187        if matches {
188            let after = i + kw_len;
189            let after_ok = after >= len || !is_word_char(bytes[after]);
190            let all_code = (i..i + kw_len).all(|k| skip_map.is_code(k));
191
192            if after_ok && all_code {
193                if count == nth {
194                    return offset_to_line_col(source, i);
195                }
196                count += 1;
197            }
198        }
199
200        i += 1;
201    }
202
203    (1, 1)
204}
205
206/// Converts a byte offset in `source` to a 1-indexed (line, col) pair.
207fn offset_to_line_col(source: &str, offset: usize) -> (usize, usize) {
208    let before = &source[..offset];
209    let line = before.chars().filter(|&c| c == '\n').count() + 1;
210    let col = before.rfind('\n').map(|p| offset - p - 1).unwrap_or(offset) + 1;
211    (line, col)
212}