sqruff_lib/rules/ambiguous/
am05.rs

1use std::str::FromStr;
2
3use ahash::AHashMap;
4use smol_str::StrExt;
5use sqruff_lib_core::dialects::syntax::{SyntaxKind, SyntaxSet};
6use sqruff_lib_core::lint_fix::LintFix;
7use sqruff_lib_core::parser::segments::SegmentBuilder;
8use strum_macros::{AsRefStr, EnumString};
9
10use crate::core::config::Value;
11use crate::core::rules::context::RuleContext;
12use crate::core::rules::crawlers::{Crawler, SegmentSeekerCrawler};
13use crate::core::rules::{Erased, ErasedRule, LintResult, Rule, RuleGroups};
14
15#[derive(Clone, Debug)]
16pub struct RuleAM05 {
17    fully_qualify_join_types: JoinType,
18}
19
20#[derive(Clone, Debug, Copy, PartialEq, Eq, Hash, AsRefStr, EnumString)]
21#[strum(serialize_all = "lowercase")]
22enum JoinType {
23    Inner,
24    Outer,
25    Both,
26}
27
28impl Default for RuleAM05 {
29    fn default() -> Self {
30        Self {
31            fully_qualify_join_types: JoinType::Inner,
32        }
33    }
34}
35
36impl Rule for RuleAM05 {
37    fn load_from_config(&self, config: &AHashMap<String, Value>) -> Result<ErasedRule, String> {
38        let fully_qualify_join_types = config["fully_qualify_join_types"].as_string();
39        // TODO We will need a more complete story for all the config parsing
40        match fully_qualify_join_types {
41            None => Err("Rule AM05 expects a `fully_qualify_join_types` array".to_string()),
42            Some(join_type) => {
43                let join_type = JoinType::from_str(join_type).map_err(|_| {
44                    format!(
45                        "Rule AM05 expects a `fully_qualify_join_types` array of valid join \
46                         types. Got: {join_type}"
47                    )
48                })?;
49                Ok(RuleAM05 {
50                    fully_qualify_join_types: join_type,
51                }
52                .erased())
53            }
54        }
55    }
56
57    fn name(&self) -> &'static str {
58        "ambiguous.join"
59    }
60
61    fn description(&self) -> &'static str {
62        "Join clauses should be fully qualified."
63    }
64
65    fn long_description(&self) -> &'static str {
66        r#"
67By default this rule is configured to enforce fully qualified `INNER JOIN` clauses, but not `[LEFT/RIGHT/FULL] OUTER JOIN`. If you prefer a stricter lint then this is configurable.
68
69* `fully_qualify_join_types`: Which types of JOIN clauses should be fully qualified? Must be one of `['inner', 'outer', 'both']`.
70
71**Anti-pattern**
72
73A join is used without specifying the kind of join.
74
75```sql
76SELECT
77    foo
78FROM bar
79JOIN baz;
80```
81
82**Best practice**
83
84Use `INNER JOIN` rather than `JOIN`.
85
86```sql
87SELECT
88    foo
89FROM bar
90INNER JOIN baz;
91```
92"#
93    }
94
95    fn groups(&self) -> &'static [RuleGroups] {
96        &[RuleGroups::All, RuleGroups::Ambiguous]
97    }
98
99    fn eval(&self, context: &RuleContext) -> Vec<LintResult> {
100        assert!(context.segment.is_type(SyntaxKind::JoinClause));
101
102        let join_clause_keywords = context
103            .segment
104            .segments()
105            .iter()
106            .filter(|segment| segment.is_type(SyntaxKind::Keyword))
107            .collect::<Vec<_>>();
108
109        // Identify LEFT/RIGHT/OUTER JOIN and if the next keyword is JOIN.
110        if (self.fully_qualify_join_types == JoinType::Outer
111            || self.fully_qualify_join_types == JoinType::Both)
112            && ["RIGHT", "LEFT", "FULL"].contains(
113                &join_clause_keywords[0]
114                    .raw()
115                    .to_uppercase_smolstr()
116                    .as_str(),
117            )
118            && join_clause_keywords[1].raw().eq_ignore_ascii_case("JOIN")
119        {
120            let outer_keyword = if join_clause_keywords[1].raw() == "JOIN" {
121                "OUTER"
122            } else {
123                "outer"
124            };
125            return vec![LintResult::new(
126                context.segment.segments()[0].clone().into(),
127                vec![LintFix::create_after(
128                    context.segment.segments()[0].clone(),
129                    vec![
130                        SegmentBuilder::whitespace(context.tables.next_id(), " "),
131                        SegmentBuilder::keyword(context.tables.next_id(), outer_keyword),
132                    ],
133                    None,
134                )],
135                None,
136                None,
137            )];
138        };
139
140        // Fully qualifying inner joins
141        if (self.fully_qualify_join_types == JoinType::Inner
142            || self.fully_qualify_join_types == JoinType::Both)
143            && join_clause_keywords[0].raw().eq_ignore_ascii_case("JOIN")
144        {
145            let inner_keyword = if join_clause_keywords[0].raw() == "JOIN" {
146                "INNER"
147            } else {
148                "inner"
149            };
150            return vec![LintResult::new(
151                context.segment.segments()[0].clone().into(),
152                vec![LintFix::create_before(
153                    context.segment.segments()[0].clone(),
154                    vec![
155                        SegmentBuilder::keyword(context.tables.next_id(), inner_keyword),
156                        SegmentBuilder::whitespace(context.tables.next_id(), " "),
157                    ],
158                )],
159                None,
160                None,
161            )];
162        }
163        vec![]
164    }
165
166    fn is_fix_compatible(&self) -> bool {
167        true
168    }
169
170    fn crawl_behaviour(&self) -> Crawler {
171        SegmentSeekerCrawler::new(const { SyntaxSet::new(&[SyntaxKind::JoinClause]) }).into()
172    }
173}