Skip to main content

sqrust_rules/convention/
order_by_with_offset.rs

1use sqrust_core::{Diagnostic, FileContext, Rule};
2use sqlparser::ast::{Expr, Query, Select, SelectItem, SetExpr, Statement, TableFactor};
3
4pub struct OrderByWithOffset;
5
6impl Rule for OrderByWithOffset {
7    fn name(&self) -> &'static str {
8        "Convention/OrderByWithOffset"
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
18        for stmt in &ctx.statements {
19            collect_from_statement(stmt, ctx, &mut diags);
20        }
21
22        diags
23    }
24}
25
26fn collect_from_statement(stmt: &Statement, ctx: &FileContext, diags: &mut Vec<Diagnostic>) {
27    if let Statement::Query(query) = stmt {
28        collect_from_query(query, ctx, diags);
29    }
30}
31
32fn collect_from_query(query: &Query, ctx: &FileContext, diags: &mut Vec<Diagnostic>) {
33    // Check CTEs.
34    if let Some(with) = &query.with {
35        for cte in &with.cte_tables {
36            collect_from_query(&cte.query, ctx, diags);
37        }
38    }
39
40    // Check this query's own OFFSET / ORDER BY.
41    let has_offset = query.offset.is_some();
42    let has_order_by = query
43        .order_by
44        .as_ref()
45        .map(|ob| !ob.exprs.is_empty())
46        .unwrap_or(false);
47
48    if has_offset && !has_order_by {
49        let (line, col) = find_keyword_pos(&ctx.source, "OFFSET");
50        diags.push(Diagnostic {
51            rule: "Convention/OrderByWithOffset",
52            message: "OFFSET without ORDER BY produces non-deterministic results".to_string(),
53            line,
54            col,
55        });
56    }
57
58    // Recurse into the body.
59    collect_from_set_expr(&query.body, ctx, diags);
60}
61
62fn collect_from_set_expr(expr: &SetExpr, ctx: &FileContext, diags: &mut Vec<Diagnostic>) {
63    match expr {
64        SetExpr::Select(select) => {
65            collect_from_select(select, ctx, diags);
66        }
67        SetExpr::Query(inner) => {
68            collect_from_query(inner, ctx, diags);
69        }
70        SetExpr::SetOperation { left, right, .. } => {
71            collect_from_set_expr(left, ctx, diags);
72            collect_from_set_expr(right, ctx, diags);
73        }
74        _ => {}
75    }
76}
77
78fn collect_from_select(select: &Select, ctx: &FileContext, diags: &mut Vec<Diagnostic>) {
79    // FROM clause — derived subqueries.
80    for table_with_joins in &select.from {
81        collect_from_table_factor(&table_with_joins.relation, ctx, diags);
82        for join in &table_with_joins.joins {
83            collect_from_table_factor(&join.relation, ctx, diags);
84        }
85    }
86
87    // WHERE clause — subquery expressions.
88    if let Some(selection) = &select.selection {
89        collect_from_expr(selection, ctx, diags);
90    }
91
92    // SELECT projection — scalar subqueries.
93    for item in &select.projection {
94        if let SelectItem::UnnamedExpr(e) | SelectItem::ExprWithAlias { expr: e, .. } = item {
95            collect_from_expr(e, ctx, diags);
96        }
97    }
98}
99
100fn collect_from_table_factor(
101    factor: &TableFactor,
102    ctx: &FileContext,
103    diags: &mut Vec<Diagnostic>,
104) {
105    if let TableFactor::Derived { subquery, .. } = factor {
106        collect_from_query(subquery, ctx, diags);
107    }
108}
109
110fn collect_from_expr(expr: &Expr, ctx: &FileContext, diags: &mut Vec<Diagnostic>) {
111    match expr {
112        Expr::Subquery(q) => collect_from_query(q, ctx, diags),
113        Expr::InSubquery { subquery, .. } => collect_from_query(subquery, ctx, diags),
114        Expr::Exists { subquery, .. } => collect_from_query(subquery, ctx, diags),
115        Expr::BinaryOp { left, right, .. } => {
116            collect_from_expr(left, ctx, diags);
117            collect_from_expr(right, ctx, diags);
118        }
119        _ => {}
120    }
121}
122
123/// Finds the first occurrence of `keyword` (case-insensitive, word-boundary)
124/// in `source` and returns a 1-indexed (line, col) pair.
125/// Falls back to (1, 1) if not found.
126fn find_keyword_pos(source: &str, keyword: &str) -> (usize, usize) {
127    let upper = source.to_uppercase();
128    let kw_upper = keyword.to_uppercase();
129    let kw_len = kw_upper.len();
130    let bytes = upper.as_bytes();
131    let len = bytes.len();
132
133    let mut pos = 0;
134    while pos + kw_len <= len {
135        if let Some(rel) = upper[pos..].find(kw_upper.as_str()) {
136            let abs = pos + rel;
137
138            let before_ok = abs == 0 || {
139                let b = bytes[abs - 1];
140                !b.is_ascii_alphanumeric() && b != b'_'
141            };
142            let after = abs + kw_len;
143            let after_ok = after >= len || {
144                let b = bytes[after];
145                !b.is_ascii_alphanumeric() && b != b'_'
146            };
147
148            if before_ok && after_ok {
149                return line_col(source, abs);
150            }
151
152            pos = abs + 1;
153        } else {
154            break;
155        }
156    }
157
158    (1, 1)
159}
160
161/// Converts a byte offset in `source` to a 1-indexed (line, col) pair.
162fn line_col(source: &str, offset: usize) -> (usize, usize) {
163    let before = &source[..offset];
164    let line = before.chars().filter(|&c| c == '\n').count() + 1;
165    let col = before.rfind('\n').map(|p| offset - p - 1).unwrap_or(offset) + 1;
166    (line, col)
167}