Skip to main content

sqrust_rules/ambiguous/
window_function_without_partition.rs

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