Skip to main content

sqrust_rules/lint/
subquery_without_alias.rs

1use sqrust_core::{Diagnostic, FileContext, Rule};
2use sqlparser::ast::{Query, SetExpr, Statement, TableFactor};
3
4pub struct SubqueryWithoutAlias;
5
6impl Rule for SubqueryWithoutAlias {
7    fn name(&self) -> &'static str {
8        "Lint/SubqueryWithoutAlias"
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            if let Statement::Query(query) = stmt {
19                check_query(query, &ctx.source, &mut diags);
20            }
21        }
22        diags
23    }
24}
25
26fn check_query(query: &Query, source: &str, diags: &mut Vec<Diagnostic>) {
27    // Check CTE bodies.
28    if let Some(with) = &query.with {
29        for cte in &with.cte_tables {
30            check_query(&cte.query, source, diags);
31        }
32    }
33    check_set_expr(&query.body, source, diags);
34}
35
36fn check_set_expr(expr: &SetExpr, source: &str, diags: &mut Vec<Diagnostic>) {
37    match expr {
38        SetExpr::Select(sel) => {
39            for table in &sel.from {
40                check_table_factor(&table.relation, source, diags);
41                for join in &table.joins {
42                    check_table_factor(&join.relation, source, diags);
43                }
44            }
45        }
46        SetExpr::SetOperation { left, right, .. } => {
47            check_set_expr(left, source, diags);
48            check_set_expr(right, source, diags);
49        }
50        SetExpr::Query(inner) => {
51            check_query(inner, source, diags);
52        }
53        _ => {}
54    }
55}
56
57fn check_table_factor(tf: &TableFactor, source: &str, diags: &mut Vec<Diagnostic>) {
58    if let TableFactor::Derived {
59        subquery, alias, ..
60    } = tf
61    {
62        if alias.is_none() {
63            // Find the opening `(` for the subquery's position in source.
64            let (line, col) = find_subquery_position(source, subquery);
65            diags.push(Diagnostic {
66                rule: "Lint/SubqueryWithoutAlias",
67                message: "Derived table (subquery in FROM) has no alias; add an alias for portability".to_string(),
68                line,
69                col,
70            });
71        }
72        // Always recurse into the subquery to catch nested unaliased derived tables.
73        check_query(subquery, source, diags);
74    }
75}
76
77/// Finds the `(SELECT` (or just `(`) that opens `subquery` inside `source`.
78/// Returns a 1-indexed (line, col) pair; falls back to (1, 1) if not found.
79fn find_subquery_position(source: &str, _subquery: &Query) -> (usize, usize) {
80    // Locate the first `(SELECT` in source (case-insensitive).
81    let source_upper = source.to_uppercase();
82    let needle = "(SELECT";
83
84    // Walk all occurrences and return the first one (leftmost).
85    if let Some(pos) = source_upper.find(needle) {
86        return offset_to_line_col(source, pos);
87    }
88
89    // Fallback: find the first bare `(`.
90    if let Some(pos) = source.find('(') {
91        return offset_to_line_col(source, pos);
92    }
93
94    (1, 1)
95}
96
97/// Converts a byte offset in `source` to a 1-indexed (line, col) pair.
98fn offset_to_line_col(source: &str, offset: usize) -> (usize, usize) {
99    let before = &source[..offset];
100    let line = before.chars().filter(|&c| c == '\n').count() + 1;
101    let col = before.rfind('\n').map(|p| offset - p - 1).unwrap_or(offset) + 1;
102    (line, col)
103}