Skip to main content

sqrust_rules/structure/
select_only_literals.rs

1use sqrust_core::{Diagnostic, FileContext, Rule};
2use sqlparser::ast::{Expr, Query, SelectItem, SetExpr, Statement};
3
4use crate::capitalisation::{is_word_char, SkipMap};
5
6pub struct SelectOnlyLiterals;
7
8impl Default for SelectOnlyLiterals {
9    fn default() -> Self {
10        SelectOnlyLiterals
11    }
12}
13
14impl Rule for SelectOnlyLiterals {
15    fn name(&self) -> &'static str {
16        "Structure/SelectOnlyLiterals"
17    }
18
19    fn check(&self, ctx: &FileContext) -> Vec<Diagnostic> {
20        if !ctx.parse_errors.is_empty() {
21            return Vec::new();
22        }
23
24        let mut diags = Vec::new();
25        // Track which SELECT keyword occurrence to point at for each flagged statement.
26        let mut select_occurrence: usize = 0;
27
28        for stmt in &ctx.statements {
29            if let Statement::Query(query) = stmt {
30                check_query(query, ctx, &mut select_occurrence, &mut diags);
31            }
32        }
33
34        diags
35    }
36}
37
38// ── AST walking ───────────────────────────────────────────────────────────────
39
40fn check_query(
41    query: &Query,
42    ctx: &FileContext,
43    select_occurrence: &mut usize,
44    diags: &mut Vec<Diagnostic>,
45) {
46    // Visit CTEs.
47    if let Some(with) = &query.with {
48        for cte in &with.cte_tables {
49            check_query(&cte.query, ctx, select_occurrence, diags);
50        }
51    }
52
53    check_set_expr(&query.body, ctx, select_occurrence, diags);
54}
55
56fn check_set_expr(
57    expr: &SetExpr,
58    ctx: &FileContext,
59    select_occurrence: &mut usize,
60    diags: &mut Vec<Diagnostic>,
61) {
62    match expr {
63        SetExpr::Select(sel) => {
64            // Only flag if there is no FROM clause and all projected items are literals.
65            if sel.from.is_empty() && !sel.projection.is_empty() {
66                let all_literals = sel.projection.iter().all(|item| match item {
67                    SelectItem::UnnamedExpr(e) | SelectItem::ExprWithAlias { expr: e, .. } => {
68                        is_literal(e)
69                    }
70                    _ => false,
71                });
72
73                if all_literals {
74                    let (line, col) =
75                        find_keyword_pos(&ctx.source, "SELECT", *select_occurrence);
76                    diags.push(Diagnostic {
77                        rule: "Structure/SelectOnlyLiterals",
78                        message:
79                            "SELECT of only literal values with no FROM clause is likely a test/debug query"
80                                .to_string(),
81                        line,
82                        col,
83                    });
84                }
85            }
86
87            *select_occurrence += 1;
88        }
89        SetExpr::Query(inner) => {
90            check_query(inner, ctx, select_occurrence, diags);
91        }
92        SetExpr::SetOperation { left, right, .. } => {
93            check_set_expr(left, ctx, select_occurrence, diags);
94            check_set_expr(right, ctx, select_occurrence, diags);
95        }
96        _ => {}
97    }
98}
99
100// ── literal detection ─────────────────────────────────────────────────────────
101
102/// Returns `true` only if `expr` is a bare SQL literal value (number, string,
103/// boolean, or NULL). Binary expressions, function calls, column references,
104/// etc. all return `false`.
105fn is_literal(expr: &Expr) -> bool {
106    matches!(expr, Expr::Value(_))
107}
108
109// ── keyword position helper ───────────────────────────────────────────────────
110
111/// Find the `nth` (0-indexed) occurrence of a keyword (case-insensitive,
112/// word-boundary, outside strings/comments) in `source`. Returns a
113/// 1-indexed (line, col) pair. Falls back to (1, 1) if not found.
114fn find_keyword_pos(source: &str, keyword: &str, nth: usize) -> (usize, usize) {
115    let bytes = source.as_bytes();
116    let len = bytes.len();
117    let skip_map = SkipMap::build(source);
118    let kw_upper: Vec<u8> = keyword.bytes().map(|b| b.to_ascii_uppercase()).collect();
119    let kw_len = kw_upper.len();
120
121    let mut count = 0usize;
122    let mut i = 0;
123    while i + kw_len <= len {
124        if !skip_map.is_code(i) {
125            i += 1;
126            continue;
127        }
128
129        // Word boundary before.
130        let before_ok = i == 0 || !is_word_char(bytes[i - 1]);
131        if !before_ok {
132            i += 1;
133            continue;
134        }
135
136        // Case-insensitive match.
137        let matches = bytes[i..i + kw_len]
138            .iter()
139            .zip(kw_upper.iter())
140            .all(|(a, b)| a.eq_ignore_ascii_case(b));
141
142        if matches {
143            // Word boundary after.
144            let after = i + kw_len;
145            let after_ok = after >= len || !is_word_char(bytes[after]);
146            let all_code = (i..i + kw_len).all(|k| skip_map.is_code(k));
147
148            if after_ok && all_code {
149                if count == nth {
150                    return line_col(source, i);
151                }
152                count += 1;
153            }
154        }
155
156        i += 1;
157    }
158
159    (1, 1)
160}
161
162/// Converts a byte offset in `source` to a 1-indexed (line, col) pair.
163fn line_col(source: &str, offset: usize) -> (usize, usize) {
164    let before = &source[..offset];
165    let line = before.chars().filter(|&c| c == '\n').count() + 1;
166    let col = before.rfind('\n').map(|p| offset - p - 1).unwrap_or(offset) + 1;
167    (line, col)
168}