Skip to main content

sqrust_rules/structure/
window_without_order_by.rs

1use sqrust_core::{Diagnostic, FileContext, Rule};
2use sqlparser::ast::{
3    Expr, Function, FunctionArg, FunctionArgExpr, FunctionArguments, OrderByExpr, Query, Select,
4    SelectItem, SetExpr, Statement, TableFactor, WindowType,
5};
6
7pub struct WindowWithoutOrderBy;
8
9impl Rule for WindowWithoutOrderBy {
10    fn name(&self) -> &'static str {
11        "WindowWithoutOrderBy"
12    }
13
14    fn check(&self, ctx: &FileContext) -> Vec<Diagnostic> {
15        if !ctx.parse_errors.is_empty() {
16            return Vec::new();
17        }
18
19        let mut diags = Vec::new();
20
21        for stmt in &ctx.statements {
22            if let Statement::Query(query) = stmt {
23                check_query(query, ctx, &mut diags);
24            }
25        }
26
27        diags
28    }
29}
30
31// ── AST walking ───────────────────────────────────────────────────────────────
32
33fn check_query(query: &Query, ctx: &FileContext, diags: &mut Vec<Diagnostic>) {
34    // Visit CTEs.
35    if let Some(with) = &query.with {
36        for cte in &with.cte_tables {
37            check_query(&cte.query, ctx, diags);
38        }
39    }
40
41    check_set_expr(&query.body, ctx, diags);
42
43    // Check ORDER BY expressions at query level.
44    if let Some(order_by) = &query.order_by {
45        for ob in &order_by.exprs {
46            check_order_by_expr(ob, ctx, diags);
47        }
48    }
49}
50
51fn check_set_expr(expr: &SetExpr, ctx: &FileContext, diags: &mut Vec<Diagnostic>) {
52    match expr {
53        SetExpr::Select(sel) => {
54            check_select(sel, ctx, diags);
55        }
56        SetExpr::Query(inner) => {
57            check_query(inner, ctx, diags);
58        }
59        SetExpr::SetOperation { left, right, .. } => {
60            check_set_expr(left, ctx, diags);
61            check_set_expr(right, ctx, diags);
62        }
63        _ => {}
64    }
65}
66
67fn check_select(sel: &Select, ctx: &FileContext, diags: &mut Vec<Diagnostic>) {
68    // Check SELECT projection expressions.
69    for item in &sel.projection {
70        if let SelectItem::UnnamedExpr(e) | SelectItem::ExprWithAlias { expr: e, .. } = item {
71            check_expr(e, ctx, diags);
72        }
73    }
74
75    // Check WHERE clause.
76    if let Some(selection) = &sel.selection {
77        check_expr(selection, ctx, diags);
78    }
79
80    // Check HAVING clause.
81    if let Some(having) = &sel.having {
82        check_expr(having, ctx, diags);
83    }
84
85    // Recurse into subqueries in FROM clause.
86    for twj in &sel.from {
87        check_table_factor(&twj.relation, ctx, diags);
88        for join in &twj.joins {
89            check_table_factor(&join.relation, ctx, diags);
90        }
91    }
92}
93
94fn check_table_factor(tf: &TableFactor, ctx: &FileContext, diags: &mut Vec<Diagnostic>) {
95    if let TableFactor::Derived { subquery, .. } = tf {
96        check_query(subquery, ctx, diags);
97    }
98}
99
100fn check_order_by_expr(ob: &OrderByExpr, ctx: &FileContext, diags: &mut Vec<Diagnostic>) {
101    check_expr(&ob.expr, ctx, diags);
102}
103
104/// Recursively walk an expression to find window functions that have a frame
105/// specification but no ORDER BY clause in the window spec.
106fn check_expr(expr: &Expr, ctx: &FileContext, diags: &mut Vec<Diagnostic>) {
107    match expr {
108        Expr::Function(func) => {
109            check_function(func, ctx, diags);
110        }
111        Expr::BinaryOp { left, right, .. } => {
112            check_expr(left, ctx, diags);
113            check_expr(right, ctx, diags);
114        }
115        Expr::UnaryOp { expr, .. } => {
116            check_expr(expr, ctx, diags);
117        }
118        Expr::Nested(e) => {
119            check_expr(e, ctx, diags);
120        }
121        Expr::IsNull(e) => {
122            check_expr(e, ctx, diags);
123        }
124        Expr::IsNotNull(e) => {
125            check_expr(e, ctx, diags);
126        }
127        Expr::Case {
128            operand,
129            conditions,
130            results,
131            else_result,
132        } => {
133            if let Some(op) = operand {
134                check_expr(op, ctx, diags);
135            }
136            for c in conditions {
137                check_expr(c, ctx, diags);
138            }
139            for r in results {
140                check_expr(r, ctx, diags);
141            }
142            if let Some(el) = else_result {
143                check_expr(el, ctx, diags);
144            }
145        }
146        Expr::Subquery(q) => {
147            check_query(q, ctx, diags);
148        }
149        Expr::InSubquery { subquery, .. } => {
150            check_query(subquery, ctx, diags);
151        }
152        Expr::Exists { subquery, .. } => {
153            check_query(subquery, ctx, diags);
154        }
155        _ => {}
156    }
157}
158
159/// Check a Function node: if it has a window spec with a frame but no ORDER BY,
160/// emit a diagnostic.
161fn check_function(func: &Function, ctx: &FileContext, diags: &mut Vec<Diagnostic>) {
162    if let Some(WindowType::WindowSpec(spec)) = &func.over {
163        if spec.window_frame.is_some() && spec.order_by.is_empty() {
164            let (line, col) = find_over_pos(&ctx.source);
165            diags.push(Diagnostic {
166                rule: "WindowWithoutOrderBy",
167                message: "Window function has a frame specification but no ORDER BY; results are non-deterministic".to_string(),
168                line,
169                col,
170            });
171        }
172    }
173
174    // Recurse into function arguments — they can contain window functions too.
175    if let FunctionArguments::List(list) = &func.args {
176        for arg in &list.args {
177            let fae = match arg {
178                FunctionArg::Named { arg, .. }
179                | FunctionArg::ExprNamed { arg, .. }
180                | FunctionArg::Unnamed(arg) => arg,
181            };
182            if let FunctionArgExpr::Expr(e) = fae {
183                check_expr(e, ctx, diags);
184            }
185        }
186    }
187}
188
189// ── keyword position helper ───────────────────────────────────────────────────
190
191/// Scan source for the first `OVER` keyword (case-insensitive, word-boundary)
192/// and return its 1-indexed (line, col). Falls back to (1, 1).
193fn find_over_pos(source: &str) -> (usize, usize) {
194    let keyword = "OVER";
195    let upper = source.to_uppercase();
196    let kw_len = keyword.len();
197    let bytes = upper.as_bytes();
198    let len = bytes.len();
199
200    let mut pos = 0;
201    while pos + kw_len <= len {
202        if let Some(rel) = upper[pos..].find(keyword) {
203            let abs = pos + rel;
204
205            let before_ok = abs == 0 || {
206                let b = bytes[abs - 1];
207                !b.is_ascii_alphanumeric() && b != b'_'
208            };
209            let after = abs + kw_len;
210            let after_ok = after >= len || {
211                let b = bytes[after];
212                !b.is_ascii_alphanumeric() && b != b'_'
213            };
214
215            if before_ok && after_ok {
216                return line_col(source, abs);
217            }
218
219            pos = abs + 1;
220        } else {
221            break;
222        }
223    }
224
225    (1, 1)
226}
227
228/// Converts a byte offset in `source` to a 1-indexed (line, col) pair.
229fn line_col(source: &str, offset: usize) -> (usize, usize) {
230    let before = &source[..offset];
231    let line = before.chars().filter(|&c| c == '\n').count() + 1;
232    let col = before.rfind('\n').map(|p| offset - p - 1).unwrap_or(offset) + 1;
233    (line, col)
234}