Skip to main content

rigsql_rules/structure/
st03.rs

1use rigsql_core::{Segment, SegmentType};
2
3use crate::rule::{CrawlType, Rule, RuleContext, RuleGroup};
4use crate::violation::LintViolation;
5
6/// ST03: Query defines a CTE but does not use it.
7///
8/// All CTEs defined in a WITH clause should be referenced in the main query
9/// or in another CTE.
10#[derive(Debug, Default)]
11pub struct RuleST03;
12
13impl Rule for RuleST03 {
14    fn code(&self) -> &'static str {
15        "ST03"
16    }
17    fn name(&self) -> &'static str {
18        "structure.unused_cte"
19    }
20    fn description(&self) -> &'static str {
21        "Query defines a CTE but does not use it."
22    }
23    fn explanation(&self) -> &'static str {
24        "Every CTE (Common Table Expression) defined in a WITH clause should be \
25         referenced in the main query or in another CTE. Unused CTEs add complexity \
26         without benefit and should be removed."
27    }
28    fn groups(&self) -> &[RuleGroup] {
29        &[RuleGroup::Structure]
30    }
31    fn is_fixable(&self) -> bool {
32        false
33    }
34
35    fn crawl_type(&self) -> CrawlType {
36        CrawlType::Segment(vec![SegmentType::WithClause])
37    }
38
39    fn eval(&self, ctx: &RuleContext) -> Vec<LintViolation> {
40        let children = ctx.segment.children();
41
42        // Collect CTE names
43        let mut cte_names: Vec<(String, rigsql_core::Span)> = Vec::new();
44        for child in children {
45            if child.segment_type() == SegmentType::CteDefinition {
46                if let Some(name) = extract_cte_name(child) {
47                    cte_names.push((name.to_lowercase(), child.span()));
48                }
49            }
50        }
51
52        if cte_names.is_empty() {
53            return vec![];
54        }
55
56        // Search the root (File) for references, not just the parent statement.
57        // When parsing partially fails, references may end up in sibling Unparsable
58        // segments outside the parent SelectStatement.
59        let raw = ctx.root.raw().to_lowercase();
60
61        let mut violations = Vec::new();
62        for (name, span) in &cte_names {
63            // Count occurrences - name appears at least once in its definition
64            let count = raw.matches(name.as_str()).count();
65            if count <= 1 {
66                violations.push(LintViolation::new(
67                    self.code(),
68                    format!("CTE '{}' is defined but not used.", name),
69                    *span,
70                ));
71            }
72        }
73
74        violations
75    }
76}
77
78fn extract_cte_name(cte_def: &Segment) -> Option<String> {
79    for child in cte_def.children() {
80        let st = child.segment_type();
81        if st == SegmentType::Identifier || st == SegmentType::QuotedIdentifier {
82            if let Segment::Token(t) = child {
83                return Some(t.token.text.to_string());
84            }
85        }
86        if st == SegmentType::Keyword {
87            break;
88        }
89    }
90    None
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96    use crate::test_utils::lint_sql;
97
98    #[test]
99    fn test_st03_flags_unused_cte() {
100        let violations = lint_sql(
101            "WITH unused AS (SELECT 1) SELECT * FROM other_table;",
102            RuleST03,
103        );
104        assert_eq!(violations.len(), 1);
105        assert!(violations[0].message.contains("unused"));
106    }
107
108    #[test]
109    fn test_st03_accepts_used_cte() {
110        let violations = lint_sql("WITH cte AS (SELECT 1) SELECT * FROM cte;", RuleST03);
111        assert_eq!(violations.len(), 0);
112    }
113
114    #[test]
115    fn test_st03_accepts_no_cte() {
116        let violations = lint_sql("SELECT * FROM t;", RuleST03);
117        assert_eq!(violations.len(), 0);
118    }
119}