Skip to main content

sqrust_rules/ambiguous/
redundant_between.rs

1use sqrust_core::{Diagnostic, FileContext, Rule};
2use sqlparser::ast::{Expr, Query, Select, SelectItem, SetExpr, Statement, TableFactor, Value};
3
4pub struct RedundantBetween;
5
6impl Rule for RedundantBetween {
7    fn name(&self) -> &'static str {
8        "Ambiguous/RedundantBetween"
9    }
10
11    fn check(&self, ctx: &FileContext) -> Vec<Diagnostic> {
12        if !ctx.parse_errors.is_empty() {
13            return Vec::new();
14        }
15
16        let mut diags = Vec::new();
17        for stmt in &ctx.statements {
18            collect_from_statement(stmt, ctx, &mut diags);
19        }
20        diags
21    }
22}
23
24fn collect_from_statement(stmt: &Statement, ctx: &FileContext, diags: &mut Vec<Diagnostic>) {
25    if let Statement::Query(query) = stmt {
26        collect_from_query(query, ctx, diags);
27    }
28}
29
30fn collect_from_query(query: &Query, ctx: &FileContext, diags: &mut Vec<Diagnostic>) {
31    if let Some(with) = &query.with {
32        for cte in &with.cte_tables {
33            collect_from_query(&cte.query, ctx, diags);
34        }
35    }
36    collect_from_set_expr(&query.body, ctx, diags);
37}
38
39fn collect_from_set_expr(expr: &SetExpr, ctx: &FileContext, diags: &mut Vec<Diagnostic>) {
40    match expr {
41        SetExpr::Select(select) => {
42            collect_from_select(select, ctx, diags);
43        }
44        SetExpr::Query(inner) => {
45            collect_from_query(inner, ctx, diags);
46        }
47        SetExpr::SetOperation { left, right, .. } => {
48            collect_from_set_expr(left, ctx, diags);
49            collect_from_set_expr(right, ctx, diags);
50        }
51        _ => {}
52    }
53}
54
55fn collect_from_select(select: &Select, ctx: &FileContext, diags: &mut Vec<Diagnostic>) {
56    // SELECT projection.
57    for item in &select.projection {
58        if let SelectItem::UnnamedExpr(e) | SelectItem::ExprWithAlias { expr: e, .. } = item {
59            check_expr(e, ctx, diags);
60        }
61    }
62
63    // FROM subqueries.
64    for twj in &select.from {
65        collect_from_table_factor(&twj.relation, ctx, diags);
66        for join in &twj.joins {
67            collect_from_table_factor(&join.relation, ctx, diags);
68        }
69    }
70
71    // WHERE clause.
72    if let Some(selection) = &select.selection {
73        check_expr(selection, ctx, diags);
74    }
75
76    // HAVING clause.
77    if let Some(having) = &select.having {
78        check_expr(having, ctx, diags);
79    }
80}
81
82fn collect_from_table_factor(
83    factor: &TableFactor,
84    ctx: &FileContext,
85    diags: &mut Vec<Diagnostic>,
86) {
87    if let TableFactor::Derived { subquery, .. } = factor {
88        collect_from_query(subquery, ctx, diags);
89    }
90}
91
92/// Returns `true` when the expression is a literal type or plain identifier
93/// that is safe to compare for redundancy without false positives.
94fn is_safe_bound(expr: &Expr) -> bool {
95    matches!(
96        expr,
97        Expr::Value(Value::Number(_, _))
98            | Expr::Value(Value::SingleQuotedString(_))
99            | Expr::Value(Value::Boolean(_))
100            | Expr::Identifier(_)
101    )
102}
103
104fn check_expr(expr: &Expr, ctx: &FileContext, diags: &mut Vec<Diagnostic>) {
105    match expr {
106        Expr::Between {
107            expr: inner,
108            negated,
109            low,
110            high,
111        } => {
112            // Recurse into the subject and bounds.
113            check_expr(inner, ctx, diags);
114            check_expr(low, ctx, diags);
115            check_expr(high, ctx, diags);
116
117            // Only flag when both bounds are safe-to-compare literals or identifiers
118            // and they are equal.
119            if is_safe_bound(low) && is_safe_bound(high) && low == high {
120                let keyword = if *negated { "NOT BETWEEN" } else { "BETWEEN" };
121                let (line, col) = find_keyword_position(&ctx.source, keyword);
122                let message = if *negated {
123                    "NOT BETWEEN with identical bounds; use != instead".to_string()
124                } else {
125                    "BETWEEN with identical bounds; use = instead".to_string()
126                };
127                diags.push(Diagnostic {
128                    rule: "Ambiguous/RedundantBetween",
129                    message,
130                    line,
131                    col,
132                });
133            }
134        }
135
136        // Recurse through other expression types to find nested BETWEEN nodes.
137        Expr::BinaryOp { left, right, .. } => {
138            check_expr(left, ctx, diags);
139            check_expr(right, ctx, diags);
140        }
141        Expr::UnaryOp { expr: inner, .. } => {
142            check_expr(inner, ctx, diags);
143        }
144        Expr::Nested(inner) => {
145            check_expr(inner, ctx, diags);
146        }
147        Expr::Case {
148            operand,
149            conditions,
150            results,
151            else_result,
152        } => {
153            if let Some(op) = operand {
154                check_expr(op, ctx, diags);
155            }
156            for cond in conditions {
157                check_expr(cond, ctx, diags);
158            }
159            for result in results {
160                check_expr(result, ctx, diags);
161            }
162            if let Some(else_e) = else_result {
163                check_expr(else_e, ctx, diags);
164            }
165        }
166        Expr::InList { expr: inner, list, .. } => {
167            check_expr(inner, ctx, diags);
168            for e in list {
169                check_expr(e, ctx, diags);
170            }
171        }
172        Expr::IsNull(inner) | Expr::IsNotNull(inner) => {
173            check_expr(inner, ctx, diags);
174        }
175        Expr::Subquery(q) | Expr::InSubquery { subquery: q, .. } | Expr::Exists { subquery: q, .. } => {
176            collect_from_query(q, ctx, diags);
177        }
178        _ => {}
179    }
180}
181
182/// Finds the first word-boundary occurrence of `keyword` (case-insensitive) in
183/// `source` and returns a 1-indexed (line, col). Falls back to (1, 1).
184fn find_keyword_position(source: &str, keyword: &str) -> (usize, usize) {
185    let upper = source.to_uppercase();
186    let kw_upper = keyword.to_uppercase();
187    let kw_len = kw_upper.len();
188    let bytes = upper.as_bytes();
189    let len = bytes.len();
190
191    let mut pos = 0;
192    while pos + kw_len <= len {
193        if let Some(rel) = upper[pos..].find(kw_upper.as_str()) {
194            let abs = pos + rel;
195
196            let before_ok = abs == 0 || {
197                let b = bytes[abs - 1];
198                !b.is_ascii_alphanumeric() && b != b'_'
199            };
200            let after = abs + kw_len;
201            let after_ok = after >= len || {
202                let b = bytes[after];
203                !b.is_ascii_alphanumeric() && b != b'_'
204            };
205
206            if before_ok && after_ok {
207                return line_col(source, abs);
208            }
209
210            pos = abs + 1;
211        } else {
212            break;
213        }
214    }
215
216    (1, 1)
217}
218
219/// Converts a byte offset to 1-indexed (line, col).
220fn line_col(source: &str, offset: usize) -> (usize, usize) {
221    let before = &source[..offset];
222    let line = before.chars().filter(|&c| c == '\n').count() + 1;
223    let col = before.rfind('\n').map(|p| offset - p - 1).unwrap_or(offset) + 1;
224    (line, col)
225}