Skip to main content

sqrust_rules/structure/
update_with_join.rs

1use sqrust_core::{Diagnostic, FileContext, Rule};
2use sqlparser::ast::Statement;
3
4pub struct UpdateWithJoin;
5
6impl Rule for UpdateWithJoin {
7    fn name(&self) -> &'static str {
8        "Structure/UpdateWithJoin"
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        let source = &ctx.source;
18        let source_upper = source.to_uppercase();
19
20        for stmt in &ctx.statements {
21            if let Statement::Update { table, from, .. } = stmt {
22                // Flag when the table itself has JOINs (e.g. MySQL-style
23                // `UPDATE t JOIN s ON … SET …`) OR when a FROM clause is
24                // present (PostgreSQL/SQL-Server `UPDATE t … FROM s …`).
25                let table_has_join = !table.joins.is_empty();
26                let has_from = from.is_some();
27
28                if table_has_join || has_from {
29                    let (line, col) =
30                        find_keyword_position(source, &source_upper, "UPDATE");
31                    diags.push(Diagnostic {
32                        rule: self.name(),
33                        message:
34                            "UPDATE with JOIN/FROM syntax is SQL Server/PostgreSQL-specific \
35                             — use a correlated subquery for portability"
36                                .to_string(),
37                        line,
38                        col,
39                    });
40                }
41            }
42        }
43
44        diags
45    }
46}
47
48// ── keyword position helper ───────────────────────────────────────────────────
49
50/// Find the first occurrence of `keyword` (already upper-cased) in
51/// `source_upper` that has word boundaries on both sides. Returns a 1-indexed
52/// (line, col) pair. Falls back to (1, 1) if not found.
53fn find_keyword_position(source: &str, source_upper: &str, keyword: &str) -> (usize, usize) {
54    let kw_len = keyword.len();
55    let bytes = source_upper.as_bytes();
56    let text_len = bytes.len();
57
58    let mut search_from = 0usize;
59    while search_from < text_len {
60        let Some(rel) = source_upper[search_from..].find(keyword) else {
61            break;
62        };
63        let abs = search_from + rel;
64
65        let before_ok = abs == 0
66            || {
67                let b = bytes[abs - 1];
68                !b.is_ascii_alphanumeric() && b != b'_'
69            };
70        let after = abs + kw_len;
71        let after_ok = after >= text_len
72            || {
73                let b = bytes[after];
74                !b.is_ascii_alphanumeric() && b != b'_'
75            };
76
77        if before_ok && after_ok {
78            return offset_to_line_col(source, abs);
79        }
80        search_from = abs + 1;
81    }
82
83    (1, 1)
84}
85
86/// Converts a byte offset in `source` to a 1-indexed (line, col) pair.
87fn offset_to_line_col(source: &str, offset: usize) -> (usize, usize) {
88    let mut line = 1usize;
89    let mut col = 1usize;
90    for (i, ch) in source.char_indices() {
91        if i == offset {
92            break;
93        }
94        if ch == '\n' {
95            line += 1;
96            col = 1;
97        } else {
98            col += 1;
99        }
100    }
101    (line, col)
102}