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
27fn 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 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 let mut used: std::collections::HashSet<String> = std::collections::HashSet::new();
72
73 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 if let Some(selection) = &sel.selection {
85 collect_qualifiers_in_expr(selection, &mut used);
86 }
87
88 if let Some(having) = &sel.having {
90 collect_qualifiers_in_expr(having, &mut used);
91 }
92
93 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 for q in extra_qualifiers {
102 used.insert(q.clone());
103 }
104
105 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 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_table_factor(&join.relation, ctx, diags);
130 }
131
132 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
143fn collect_qualifiers_in_expr(expr: &Expr, qualifiers: &mut std::collections::HashSet<String>) {
148 match expr {
149 Expr::CompoundIdentifier(parts) if parts.len() >= 2 => {
150 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 }
207 _ => {}
208 }
209}
210
211fn 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
227fn 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}