sqrust_rules/ambiguous/
inconsistent_order_by_direction.rs1use sqrust_core::{Diagnostic, FileContext, Rule};
2
3use crate::capitalisation::{is_word_char, SkipMap};
4use super::group_by_position::{match_keyword, skip_whitespace};
5
6pub struct InconsistentOrderByDirection;
7
8impl Rule for InconsistentOrderByDirection {
9 fn name(&self) -> &'static str {
10 "Ambiguous/InconsistentOrderByDirection"
11 }
12
13 fn check(&self, ctx: &FileContext) -> Vec<Diagnostic> {
14 let source = &ctx.source;
15 let bytes = source.as_bytes();
16 let len = bytes.len();
17 let skip_map = SkipMap::build(source);
18 let mut diags = Vec::new();
19 let mut i = 0;
20
21 while i < len {
22 if !skip_map.is_code(i) {
23 i += 1;
24 continue;
25 }
26
27 if let Some(after_order) = match_keyword(bytes, &skip_map, i, b"ORDER") {
28 let after_ws = skip_whitespace(bytes, after_order);
29 if let Some(after_by) = match_keyword(bytes, &skip_map, after_ws, b"BY") {
30 if is_inconsistent_direction(bytes, &skip_map, after_by) {
31 let (line, col) = offset_to_line_col(source, i);
32 diags.push(Diagnostic {
33 rule: "Ambiguous/InconsistentOrderByDirection",
34 message: "ORDER BY mixes explicit direction (ASC/DESC) with implicit; specify direction for all columns".to_string(),
35 line,
36 col,
37 });
38 }
39 i = after_by;
40 continue;
41 }
42 }
43
44 i += 1;
45 }
46
47 diags
48 }
49}
50
51const ORDER_BY_STOP: &[&[u8]] = &[
53 b"LIMIT", b"UNION", b"INTERSECT", b"EXCEPT", b"FETCH", b"OFFSET", b"FOR",
54];
55
56fn offset_to_line_col(source: &str, offset: usize) -> (usize, usize) {
58 let before = &source[..offset];
59 let line = before.chars().filter(|&c| c == '\n').count() + 1;
60 let col = before.rfind('\n').map(|p| offset - p - 1).unwrap_or(offset) + 1;
61 (line, col)
62}
63
64fn last_word(bytes: &[u8], start: usize, end: usize) -> &[u8] {
67 let mut e = end;
69 while e > start
70 && (bytes[e - 1] == b' '
71 || bytes[e - 1] == b'\t'
72 || bytes[e - 1] == b'\n'
73 || bytes[e - 1] == b'\r')
74 {
75 e -= 1;
76 }
77 if e <= start {
78 return &[];
79 }
80 let word_end = e;
82 let mut word_start = e;
83 while word_start > start && is_word_char(bytes[word_start - 1]) {
84 word_start -= 1;
85 }
86 &bytes[word_start..word_end]
87}
88
89fn is_inconsistent_direction(bytes: &[u8], skip_map: &SkipMap, start: usize) -> bool {
92 let len = bytes.len();
93 let mut i = start;
94 let mut has_explicit = false;
95 let mut has_implicit = false;
96
97 'outer: loop {
98 while i < len
100 && (bytes[i] == b' '
101 || bytes[i] == b'\t'
102 || bytes[i] == b'\n'
103 || bytes[i] == b'\r')
104 {
105 i += 1;
106 }
107
108 if i >= len {
109 break;
110 }
111
112 if skip_map.is_code(i) && (bytes[i] == b';' || bytes[i] == b')') {
114 break;
115 }
116
117 for &stop in ORDER_BY_STOP {
119 if match_keyword(bytes, skip_map, i, stop).is_some() {
120 break 'outer;
121 }
122 }
123
124 let item_start = i;
126 let mut item_end = i;
127 let mut depth = 0usize;
128
129 while item_end < len {
130 if !skip_map.is_code(item_end) {
131 item_end += 1;
132 continue;
133 }
134
135 let b = bytes[item_end];
136
137 if b == b'(' {
138 depth += 1;
139 item_end += 1;
140 continue;
141 }
142 if b == b')' {
143 if depth == 0 {
144 break;
145 }
146 depth -= 1;
147 item_end += 1;
148 continue;
149 }
150 if depth == 0 {
151 if b == b',' || b == b';' {
152 break;
153 }
154 let mut stopped = false;
155 for &stop in ORDER_BY_STOP {
156 if match_keyword(bytes, skip_map, item_end, stop).is_some() {
157 stopped = true;
158 break;
159 }
160 }
161 if stopped {
162 break;
163 }
164 }
165
166 item_end += 1;
167 }
168
169 let mut effective_end = item_end;
171 let w = last_word(bytes, item_start, effective_end);
172
173 if w.eq_ignore_ascii_case(b"FIRST") || w.eq_ignore_ascii_case(b"LAST") {
175 effective_end -= w.len();
177 while effective_end > item_start
179 && (bytes[effective_end - 1] == b' '
180 || bytes[effective_end - 1] == b'\t'
181 || bytes[effective_end - 1] == b'\n'
182 || bytes[effective_end - 1] == b'\r')
183 {
184 effective_end -= 1;
185 }
186 let w2 = last_word(bytes, item_start, effective_end);
187 if w2.eq_ignore_ascii_case(b"NULLS") {
189 effective_end -= w2.len();
190 while effective_end > item_start
191 && (bytes[effective_end - 1] == b' '
192 || bytes[effective_end - 1] == b'\t'
193 || bytes[effective_end - 1] == b'\n'
194 || bytes[effective_end - 1] == b'\r')
195 {
196 effective_end -= 1;
197 }
198 }
199 }
200
201 let final_word = last_word(bytes, item_start, effective_end);
202
203 if final_word.eq_ignore_ascii_case(b"ASC") || final_word.eq_ignore_ascii_case(b"DESC") {
204 has_explicit = true;
205 } else if !final_word.is_empty() {
206 has_implicit = true;
207 }
208
209 if item_end < len && bytes[item_end] == b',' {
211 i = item_end + 1;
212 } else {
213 break;
214 }
215 }
216
217 has_explicit && has_implicit
218}