Skip to main content

sqrust_rules/ambiguous/
having_without_group_by.rs

1use sqrust_core::{Diagnostic, FileContext, Rule};
2use sqlparser::ast::{GroupByExpr, Query, SetExpr, Statement, TableFactor};
3
4pub struct HavingWithoutGroupBy;
5
6impl Rule for HavingWithoutGroupBy {
7    fn name(&self) -> &'static str {
8        "Ambiguous/HavingWithoutGroupBy"
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, self.name(), &mut diags);
20            }
21        }
22        diags
23    }
24}
25
26fn check_query(query: &Query, source: &str, rule: &'static str, diags: &mut Vec<Diagnostic>) {
27    // Recurse into CTEs.
28    if let Some(with) = &query.with {
29        for cte in &with.cte_tables {
30            check_query(&cte.query, source, rule, diags);
31        }
32    }
33    check_set_expr(&query.body, source, rule, diags);
34}
35
36fn check_set_expr(expr: &SetExpr, source: &str, rule: &'static str, diags: &mut Vec<Diagnostic>) {
37    match expr {
38        SetExpr::Select(sel) => {
39            let has_having = sel.having.is_some();
40
41            // `GroupByExpr::Expressions(exprs, _)` with empty `exprs` means no GROUP BY.
42            // `GroupByExpr::All(_)` means `GROUP BY ALL` — that counts as having a GROUP BY.
43            let has_group_by = match &sel.group_by {
44                GroupByExpr::All(_) => true,
45                GroupByExpr::Expressions(exprs, _) => !exprs.is_empty(),
46            };
47
48            if has_having && !has_group_by {
49                // Find HAVING keyword position in the source text.
50                let (line, col) = find_keyword_position(source, "HAVING");
51                diags.push(Diagnostic {
52                    rule,
53                    message: "HAVING without GROUP BY; did you mean WHERE?".to_string(),
54                    line,
55                    col,
56                });
57            }
58
59            // Recurse into subqueries inside FROM / JOIN clauses.
60            for table in &sel.from {
61                recurse_table_factor(&table.relation, source, rule, diags);
62                for join in &table.joins {
63                    recurse_table_factor(&join.relation, source, rule, diags);
64                }
65            }
66        }
67        SetExpr::SetOperation { left, right, .. } => {
68            check_set_expr(left, source, rule, diags);
69            check_set_expr(right, source, rule, diags);
70        }
71        SetExpr::Query(inner) => {
72            check_query(inner, source, rule, diags);
73        }
74        _ => {}
75    }
76}
77
78fn recurse_table_factor(
79    tf: &TableFactor,
80    source: &str,
81    rule: &'static str,
82    diags: &mut Vec<Diagnostic>,
83) {
84    if let TableFactor::Derived { subquery, .. } = tf {
85        check_query(subquery, source, rule, diags);
86    }
87}
88
89/// Finds the first occurrence of `keyword` (case-insensitive, word-boundary-checked)
90/// in `source` and returns a 1-indexed (line, col). Falls back to (1, 1) if not found.
91fn find_keyword_position(source: &str, keyword: &str) -> (usize, usize) {
92    let upper = source.to_uppercase();
93    let kw_upper = keyword.to_uppercase();
94    let bytes = upper.as_bytes();
95    let kw_bytes = kw_upper.as_bytes();
96    let kw_len = kw_bytes.len();
97
98    let mut i = 0;
99    while i + kw_len <= bytes.len() {
100        if bytes[i..i + kw_len] == *kw_bytes {
101            let before_ok = i == 0
102                || (!bytes[i - 1].is_ascii_alphanumeric() && bytes[i - 1] != b'_');
103            let after = i + kw_len;
104            let after_ok = after >= bytes.len()
105                || (!bytes[after].is_ascii_alphanumeric() && bytes[after] != b'_');
106            if before_ok && after_ok {
107                return offset_to_line_col(source, i);
108            }
109        }
110        i += 1;
111    }
112    (1, 1)
113}
114
115/// Converts a byte offset in `source` to a 1-indexed (line, col) pair.
116fn offset_to_line_col(source: &str, offset: usize) -> (usize, usize) {
117    let mut line = 1usize;
118    let mut col = 1usize;
119    for (i, ch) in source.char_indices() {
120        if i == offset {
121            break;
122        }
123        if ch == '\n' {
124            line += 1;
125            col = 1;
126        } else {
127            col += 1;
128        }
129    }
130    (line, col)
131}