Skip to main content

sqrust_rules/structure/
unused_join.rs

1use sqrust_core::{Diagnostic, FileContext, Rule};
2use sqlparser::ast::{Expr, FunctionArg, FunctionArgExpr, FunctionArguments, GroupByExpr, Query, Select, SelectItem, SetExpr, Statement, TableFactor};
3
4use crate::capitalisation::{is_word_char, SkipMap};
5
6pub struct UnusedJoin;
7
8impl Rule for UnusedJoin {
9    fn name(&self) -> &'static str {
10        "Structure/UnusedJoin"
11    }
12
13    fn check(&self, ctx: &FileContext) -> Vec<Diagnostic> {
14        if !ctx.parse_errors.is_empty() {
15            return Vec::new();
16        }
17        let mut diags = Vec::new();
18        for stmt in &ctx.statements {
19            if let Statement::Query(q) = stmt {
20                check_query(q, ctx, &mut diags);
21            }
22        }
23        diags
24    }
25}
26
27// ── AST walking ───────────────────────────────────────────────────────────────
28
29fn check_query(q: &Query, ctx: &FileContext, diags: &mut Vec<Diagnostic>) {
30    if let Some(with) = &q.with {
31        for cte in &with.cte_tables {
32            check_query(&cte.query, ctx, diags);
33        }
34    }
35
36    // Collect ORDER BY qualifiers from this query level.
37    let mut order_qualifiers: std::collections::HashSet<String> = std::collections::HashSet::new();
38    if let Some(order_by) = &q.order_by {
39        for ob_expr in &order_by.exprs {
40            collect_qualifiers_in_expr(&ob_expr.expr, &mut order_qualifiers);
41        }
42    }
43
44    check_set_expr(&q.body, ctx, &order_qualifiers, diags);
45}
46
47fn check_set_expr(
48    expr: &SetExpr,
49    ctx: &FileContext,
50    order_qualifiers: &std::collections::HashSet<String>,
51    diags: &mut Vec<Diagnostic>,
52) {
53    match expr {
54        SetExpr::Select(sel) => check_select(sel, ctx, order_qualifiers, diags),
55        SetExpr::Query(inner) => check_query(inner, ctx, diags),
56        SetExpr::SetOperation { left, right, .. } => {
57            check_set_expr(left, ctx, order_qualifiers, diags);
58            check_set_expr(right, ctx, order_qualifiers, diags);
59        }
60        _ => {}
61    }
62}
63
64fn check_select(
65    sel: &Select,
66    ctx: &FileContext,
67    extra_qualifiers: &std::collections::HashSet<String>,
68    diags: &mut Vec<Diagnostic>,
69) {
70    // Collect qualifiers used in SELECT projection, WHERE, HAVING, GROUP BY.
71    let mut used: std::collections::HashSet<String> = std::collections::HashSet::new();
72
73    // SELECT projection.
74    for item in &sel.projection {
75        match item {
76            SelectItem::UnnamedExpr(e) | SelectItem::ExprWithAlias { expr: e, .. } => {
77                collect_qualifiers_in_expr(e, &mut used);
78            }
79            SelectItem::Wildcard(_) | SelectItem::QualifiedWildcard(_, _) => {}
80        }
81    }
82
83    // WHERE.
84    if let Some(selection) = &sel.selection {
85        collect_qualifiers_in_expr(selection, &mut used);
86    }
87
88    // HAVING.
89    if let Some(having) = &sel.having {
90        collect_qualifiers_in_expr(having, &mut used);
91    }
92
93    // GROUP BY.
94    if let GroupByExpr::Expressions(exprs, _) = &sel.group_by {
95        for e in exprs {
96            collect_qualifiers_in_expr(e, &mut used);
97        }
98    }
99
100    // Add any extra qualifiers (e.g. from ORDER BY).
101    for q in extra_qualifiers {
102        used.insert(q.clone());
103    }
104
105    // Check each JOIN.
106    for twj in &sel.from {
107        for join in &twj.joins {
108            if let Some(ref_name) = table_factor_ref_name(&join.relation) {
109                let ref_upper = ref_name.to_uppercase();
110                if !used.contains(&ref_upper) {
111                    // Not used in SELECT/WHERE/HAVING/GROUP BY/ORDER BY.
112                    let source = &ctx.source;
113                    let def_pos = find_word_position(source, &ref_name).unwrap_or(0);
114                    let (line, col) = offset_to_line_col(source, def_pos);
115                    diags.push(Diagnostic {
116                        rule: "Structure/UnusedJoin",
117                        message: format!(
118                            "JOIN table '{}' is never referenced in query output; \
119                             the join may be unnecessary",
120                            ref_name
121                        ),
122                        line,
123                        col,
124                    });
125                }
126            }
127
128            // Recurse into derived subqueries inside the JOIN.
129            recurse_table_factor(&join.relation, ctx, diags);
130        }
131
132        // Recurse into derived subqueries in the primary FROM table.
133        recurse_table_factor(&twj.relation, ctx, diags);
134    }
135}
136
137fn recurse_table_factor(tf: &TableFactor, ctx: &FileContext, diags: &mut Vec<Diagnostic>) {
138    if let TableFactor::Derived { subquery, .. } = tf {
139        check_query(subquery, ctx, diags);
140    }
141}
142
143// ── Qualifier collection ───────────────────────────────────────────────────────
144
145/// Collect all `qualifier` parts from `table.column` compound identifiers.
146/// E.g. `b.name` contributes `"B"` to the set.
147fn collect_qualifiers_in_expr(expr: &Expr, qualifiers: &mut std::collections::HashSet<String>) {
148    match expr {
149        Expr::CompoundIdentifier(parts) if parts.len() >= 2 => {
150            // First part is the table/alias qualifier.
151            qualifiers.insert(parts[0].value.to_uppercase());
152        }
153        Expr::BinaryOp { left, right, .. } => {
154            collect_qualifiers_in_expr(left, qualifiers);
155            collect_qualifiers_in_expr(right, qualifiers);
156        }
157        Expr::UnaryOp { expr: inner, .. } => collect_qualifiers_in_expr(inner, qualifiers),
158        Expr::Nested(inner) => collect_qualifiers_in_expr(inner, qualifiers),
159        Expr::Cast { expr: inner, .. } => collect_qualifiers_in_expr(inner, qualifiers),
160        Expr::IsNull(inner) | Expr::IsNotNull(inner) => {
161            collect_qualifiers_in_expr(inner, qualifiers)
162        }
163        Expr::Between {
164            expr: e, low, high, ..
165        } => {
166            collect_qualifiers_in_expr(e, qualifiers);
167            collect_qualifiers_in_expr(low, qualifiers);
168            collect_qualifiers_in_expr(high, qualifiers);
169        }
170        Expr::InList { expr: inner, list, .. } => {
171            collect_qualifiers_in_expr(inner, qualifiers);
172            for e in list {
173                collect_qualifiers_in_expr(e, qualifiers);
174            }
175        }
176        Expr::Case {
177            operand,
178            conditions,
179            results,
180            else_result,
181        } => {
182            if let Some(op) = operand {
183                collect_qualifiers_in_expr(op, qualifiers);
184            }
185            for cond in conditions {
186                collect_qualifiers_in_expr(cond, qualifiers);
187            }
188            for res in results {
189                collect_qualifiers_in_expr(res, qualifiers);
190            }
191            if let Some(else_e) = else_result {
192                collect_qualifiers_in_expr(else_e, qualifiers);
193            }
194        }
195        Expr::Function(f) => {
196            if let FunctionArguments::List(arg_list) = &f.args {
197                for arg in &arg_list.args {
198                    if let FunctionArg::Unnamed(FunctionArgExpr::Expr(e)) = arg {
199                        collect_qualifiers_in_expr(e, qualifiers);
200                    }
201                }
202            }
203        }
204        Expr::Subquery(_) | Expr::InSubquery { .. } | Expr::Exists { .. } => {
205            // Don't recurse into subqueries — they have their own scope.
206        }
207        _ => {}
208    }
209}
210
211// ── AST helpers ───────────────────────────────────────────────────────────────
212
213fn table_factor_ref_name(tf: &TableFactor) -> Option<String> {
214    match tf {
215        TableFactor::Table { name, alias, .. } => {
216            if let Some(a) = alias {
217                Some(a.name.value.clone())
218            } else {
219                name.0.last().map(|ident| ident.value.clone())
220            }
221        }
222        TableFactor::Derived { alias, .. } => alias.as_ref().map(|a| a.name.value.clone()),
223        _ => None,
224    }
225}
226
227// ── Source-text helpers ───────────────────────────────────────────────────────
228
229fn find_word_position(source: &str, word: &str) -> Option<usize> {
230    let bytes = source.as_bytes();
231    let word_upper: Vec<u8> = word.bytes().map(|b| b.to_ascii_uppercase()).collect();
232    let wlen = word_upper.len();
233    let len = bytes.len();
234    let skip = SkipMap::build(source);
235    let mut i = 0;
236    while i + wlen <= len {
237        if !skip.is_code(i) {
238            i += 1;
239            continue;
240        }
241        let before_ok = i == 0 || !is_word_char(bytes[i - 1]);
242        if !before_ok {
243            i += 1;
244            continue;
245        }
246        let matches = bytes[i..i + wlen]
247            .iter()
248            .zip(word_upper.iter())
249            .all(|(&a, &b)| a.to_ascii_uppercase() == b);
250        if matches {
251            let end = i + wlen;
252            let after_ok = end >= len || !is_word_char(bytes[end]);
253            if after_ok {
254                return Some(i);
255            }
256        }
257        i += 1;
258    }
259    None
260}
261
262fn offset_to_line_col(source: &str, offset: usize) -> (usize, usize) {
263    let safe = offset.min(source.len());
264    let before = &source[..safe];
265    let line = before.chars().filter(|&c| c == '\n').count() + 1;
266    let col = before.rfind('\n').map(|p| safe - p - 1).unwrap_or(safe) + 1;
267    (line, col)
268}