Skip to main content

sqrust_rules/ambiguous/
inconsistent_order_by_direction.rs

1use 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
51/// Stop keywords that terminate an ORDER BY item list.
52const ORDER_BY_STOP: &[&[u8]] = &[
53    b"LIMIT", b"UNION", b"INTERSECT", b"EXCEPT", b"FETCH", b"OFFSET", b"FOR",
54];
55
56/// Converts a byte offset to 1-indexed (line, col).
57fn 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
64/// Returns the last word token in `bytes[start..end]`, scanning backwards,
65/// ignoring trailing whitespace.
66fn last_word(bytes: &[u8], start: usize, end: usize) -> &[u8] {
67    // Trim trailing whitespace.
68    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    // Walk backward over word characters.
81    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
89/// Scans the ORDER BY item list starting at `start` (right after the BY keyword).
90/// Returns true if the clause mixes explicit direction (ASC/DESC) with implicit (no direction).
91fn 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        // Skip leading whitespace before item.
99        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        // Semicolon or closing paren terminates.
113        if skip_map.is_code(i) && (bytes[i] == b';' || bytes[i] == b')') {
114            break;
115        }
116
117        // Check stop keywords.
118        for &stop in ORDER_BY_STOP {
119            if match_keyword(bytes, skip_map, i, stop).is_some() {
120                break 'outer;
121            }
122        }
123
124        // Collect item content until comma (at depth 0) or stop.
125        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        // Determine the last word in the item, skipping NULLS FIRST / NULLS LAST.
170        let mut effective_end = item_end;
171        let w = last_word(bytes, item_start, effective_end);
172
173        // Check if last word is FIRST or LAST (part of NULLS FIRST/LAST).
174        if w.eq_ignore_ascii_case(b"FIRST") || w.eq_ignore_ascii_case(b"LAST") {
175            // Strip it off and check the word before it.
176            effective_end -= w.len();
177            // Skip trailing whitespace before stripped word.
178            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 the word before FIRST/LAST is NULLS, strip it too.
188            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        // Advance past comma.
210        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}