sqrust_rules/convention/
order_by_with_offset.rs1use 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 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 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 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 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 if let Some(selection) = &select.selection {
89 collect_from_expr(selection, ctx, diags);
90 }
91
92 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
123fn 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
161fn 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}