Skip to main content

sqruff_lib/rules/aliasing/
al05.rs

1use std::cell::RefCell;
2
3use hashbrown::{HashMap, HashSet};
4use smol_str::SmolStr;
5use sqruff_lib_core::dialects::Dialect;
6use sqruff_lib_core::dialects::common::AliasInfo;
7use sqruff_lib_core::dialects::init::DialectKind;
8use sqruff_lib_core::dialects::syntax::{SyntaxKind, SyntaxSet};
9use sqruff_lib_core::lint_fix::LintFix;
10use sqruff_lib_core::parser::segments::ErasedSegment;
11use sqruff_lib_core::parser::segments::object_reference::ObjectReferenceLevel;
12use sqruff_lib_core::utils::analysis::query::{Query, QueryInner};
13use sqruff_lib_core::utils::analysis::select::{
14    SelectStatementColumnsAndTables, get_select_statement_info,
15};
16
17use crate::core::config::Value;
18use crate::core::rules::context::RuleContext;
19use crate::core::rules::crawlers::{Crawler, SegmentSeekerCrawler};
20use crate::core::rules::{Erased, ErasedRule, LintResult, Rule, RuleGroups};
21
22#[derive(Default, Clone)]
23struct AL05QueryData {
24    aliases: Vec<AliasInfo>,
25    tbl_refs: Vec<SmolStr>,
26}
27
28type QueryKey<'a> = *const RefCell<QueryInner<'a>>;
29type AL05State<'a> = HashMap<QueryKey<'a>, AL05QueryData>;
30
31#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
32enum AliasCaseCheck {
33    #[default]
34    Dialect,
35    CaseInsensitive,
36    QuotedCaseSensitiveNakedUpper,
37    QuotedCaseSensitiveNakedLower,
38    CaseSensitive,
39}
40
41impl AliasCaseCheck {
42    fn from_config(value: &str) -> Result<Self, String> {
43        match value {
44            "dialect" => Ok(Self::Dialect),
45            "case_insensitive" => Ok(Self::CaseInsensitive),
46            "quoted_cs_naked_upper" => Ok(Self::QuotedCaseSensitiveNakedUpper),
47            "quoted_cs_naked_lower" => Ok(Self::QuotedCaseSensitiveNakedLower),
48            "case_sensitive" => Ok(Self::CaseSensitive),
49            other => Err(format!("Invalid alias_case_check value: {other}")),
50        }
51    }
52}
53
54#[derive(Debug, Default, Clone)]
55pub struct RuleAL05 {
56    alias_case_check: AliasCaseCheck,
57}
58
59impl Rule for RuleAL05 {
60    fn load_from_config(&self, config: &HashMap<String, Value>) -> Result<ErasedRule, String> {
61        Ok(RuleAL05 {
62            alias_case_check: AliasCaseCheck::from_config(
63                config["alias_case_check"].as_string().unwrap(),
64            )?,
65        }
66        .erased())
67    }
68
69    fn name(&self) -> &'static str {
70        "aliasing.unused"
71    }
72
73    fn description(&self) -> &'static str {
74        "Tables should not be aliased if that alias is not used."
75    }
76
77    fn long_description(&self) -> &'static str {
78        r#"
79**Anti-pattern**
80
81In this example, alias `zoo` is not used.
82
83```sql
84SELECT
85    a
86FROM foo AS zoo
87```
88
89**Best practice**
90
91Use the alias or remove it. An unused alias makes code harder to read without changing any functionality.
92
93```sql
94SELECT
95    zoo.a
96FROM foo AS zoo
97
98-- Alternatively...
99
100SELECT
101    a
102FROM foo
103```
104"#
105    }
106
107    fn groups(&self) -> &'static [RuleGroups] {
108        &[RuleGroups::All, RuleGroups::Core, RuleGroups::Aliasing]
109    }
110
111    fn eval(&self, context: &RuleContext) -> Vec<LintResult> {
112        let mut violations = Vec::new();
113        let select_info = get_select_statement_info(&context.segment, context.dialect.into(), true);
114
115        let Some(select_info) = select_info else {
116            return Vec::new();
117        };
118
119        if select_info.table_aliases.is_empty() {
120            return Vec::new();
121        }
122
123        let query = Query::from_segment(&context.segment, context.dialect, None);
124        let mut payloads = AL05State::default();
125        self.analyze_table_aliases(query.clone(), context.dialect, &mut payloads);
126
127        let payload = payloads.get(&query.id()).cloned().unwrap_or_default();
128
129        if matches!(
130            context.dialect.name,
131            DialectKind::Redshift | DialectKind::Bigquery
132        ) {
133            let mut references: HashSet<SmolStr> = HashSet::new();
134            let mut aliases: HashSet<SmolStr> = HashSet::new();
135
136            for alias in &payload.aliases {
137                aliases.insert(self.alias_name(alias, context.dialect.name));
138                if let Some(alias_segment) = &alias.segment {
139                    aliases.insert(self.normalize_identifier(alias_segment, context.dialect.name));
140                }
141                if let Some(object_reference) = &alias.object_reference {
142                    for seg in object_reference.segments() {
143                        if const {
144                            SyntaxSet::new(&[
145                                SyntaxKind::Identifier,
146                                SyntaxKind::NakedIdentifier,
147                                SyntaxKind::QuotedIdentifier,
148                                SyntaxKind::ObjectReference,
149                            ])
150                        }
151                        .contains(seg.get_type())
152                        {
153                            references.insert(self.normalize_identifier(seg, context.dialect.name));
154                        }
155                    }
156                }
157            }
158
159            if aliases.intersection(&references).next().is_some() {
160                return Vec::new();
161            }
162        }
163
164        let mut ref_counter: HashMap<SmolStr, usize> = HashMap::new();
165        for alias in &payload.aliases {
166            let Some(object_reference) = &alias.object_reference else {
167                continue;
168            };
169            let Some(last_segment) = object_reference.segments().last() else {
170                continue;
171            };
172
173            *ref_counter
174                .entry(self.normalize_identifier(last_segment, context.dialect.name))
175                .or_default() += 1;
176        }
177
178        for alias in &payload.aliases {
179            if Self::is_alias_required(&alias.from_expression_element, context.dialect.name) {
180                continue;
181            }
182
183            if let Some(object_reference) = &alias.object_reference
184                && let Some(last_segment) = object_reference.segments().last()
185                && ref_counter
186                    .get(&self.normalize_identifier(last_segment, context.dialect.name))
187                    .copied()
188                    .unwrap_or_default()
189                    > 1
190            {
191                continue;
192            }
193
194            if context.dialect.name == DialectKind::Redshift
195                && alias.alias_expression.is_some()
196                && self.followed_by_qualify(context, alias)
197            {
198                continue;
199            }
200
201            if self.has_function_alias_reference(alias, &select_info, context.dialect.name) {
202                continue;
203            }
204
205            if alias.aliased
206                && !payload
207                    .tbl_refs
208                    .contains(&self.alias_name(alias, context.dialect.name))
209            {
210                if Self::has_used_column_aliases(alias, &select_info, context.dialect.name) {
211                    continue;
212                }
213                let violation = self.report_unused_alias(alias);
214                violations.push(violation);
215            }
216        }
217
218        violations
219    }
220
221    fn is_fix_compatible(&self) -> bool {
222        true
223    }
224
225    fn crawl_behaviour(&self) -> Crawler {
226        SegmentSeekerCrawler::new(const { SyntaxSet::new(&[SyntaxKind::SelectStatement]) }).into()
227    }
228}
229
230impl RuleAL05 {
231    #[allow(clippy::only_used_in_recursion)]
232    fn analyze_table_aliases<'a>(
233        &self,
234        query: Query<'a>,
235        dialect: &Dialect,
236        payloads: &mut AL05State<'a>,
237    ) {
238        payloads.entry(query.id()).or_default();
239        let selectables = std::mem::take(&mut RefCell::borrow_mut(&query.inner).selectables);
240
241        for selectable in &selectables {
242            if let Some(select_info) = selectable.select_info() {
243                let table_aliases = select_info.table_aliases;
244                let reference_buffer = select_info.reference_buffer;
245                let table_reference_buffer = select_info.table_reference_buffer;
246
247                payloads
248                    .entry(query.id())
249                    .or_default()
250                    .aliases
251                    .extend(table_aliases);
252
253                for r in reference_buffer.into_iter().chain(table_reference_buffer) {
254                    for tr in
255                        r.extract_possible_references(ObjectReferenceLevel::Table, dialect.name)
256                    {
257                        self.resolve_and_mark_reference(query.clone(), &tr, dialect.name, payloads);
258                    }
259                }
260            }
261        }
262
263        RefCell::borrow_mut(&query.inner).selectables = selectables;
264
265        for child in query.children() {
266            self.analyze_table_aliases(child, dialect, payloads);
267        }
268    }
269
270    fn resolve_and_mark_reference<'a>(
271        &self,
272        query: Query<'a>,
273        reference: &sqruff_lib_core::parser::segments::object_reference::ObjectReferencePart,
274        dialect: DialectKind,
275        payloads: &mut AL05State<'a>,
276    ) {
277        let Some(reference_segment) = reference.segments.first() else {
278            return;
279        };
280        let normalized_ref = self.normalize_identifier(reference_segment, dialect);
281
282        if let Some(payload) = payloads.get_mut(&query.id())
283            && payload
284                .aliases
285                .iter()
286                .any(|it| self.alias_name(it, dialect) == normalized_ref)
287        {
288            payload.tbl_refs.push(normalized_ref);
289            return;
290        }
291
292        if let Some(parent) = RefCell::borrow(&query.inner).parent.clone() {
293            self.resolve_and_mark_reference(parent, reference, dialect, payloads);
294        }
295    }
296
297    fn is_alias_required(
298        from_expression_element: &ErasedSegment,
299        dialect_name: DialectKind,
300    ) -> bool {
301        for segment in from_expression_element
302            .iter_segments(const { &SyntaxSet::new(&[SyntaxKind::Bracketed]) }, false)
303        {
304            if segment.is_type(SyntaxKind::TableExpression) {
305                return if segment
306                    .child(const { &SyntaxSet::new(&[SyntaxKind::ValuesClause]) })
307                    .is_some()
308                {
309                    matches!(
310                        dialect_name,
311                        DialectKind::Athena
312                            | DialectKind::Snowflake
313                            | DialectKind::Tsql
314                            | DialectKind::Postgres
315                    )
316                } else {
317                    segment
318                        .iter_segments(const { &SyntaxSet::new(&[SyntaxKind::Bracketed]) }, false)
319                        .iter()
320                        .any(|seg| {
321                            const {
322                                SyntaxSet::new(&[
323                                    SyntaxKind::SelectStatement,
324                                    SyntaxKind::SetExpression,
325                                    SyntaxKind::WithCompoundStatement,
326                                ])
327                            }
328                            .contains(seg.get_type())
329                        })
330                };
331            }
332        }
333        false
334    }
335
336    fn has_used_column_aliases(
337        alias: &AliasInfo,
338        select_info: &SelectStatementColumnsAndTables,
339        dialect_name: DialectKind,
340    ) -> bool {
341        let Some(alias_expression) = &alias.alias_expression else {
342            return false;
343        };
344
345        // Look for a Bracketed child in the alias expression (the column alias
346        // list, e.g. `(value)` or `(c1, c2)`).
347        let Some(bracketed) =
348            alias_expression.child(const { &SyntaxSet::single(SyntaxKind::Bracketed) })
349        else {
350            return false;
351        };
352
353        // Collect all identifier names from the bracketed column alias list.
354        let col_alias_names: Vec<SmolStr> = bracketed
355            .recursive_crawl(
356                const {
357                    &SyntaxSet::new(&[
358                        SyntaxKind::NakedIdentifier,
359                        SyntaxKind::Identifier,
360                        SyntaxKind::QuotedIdentifier,
361                    ])
362                },
363                true,
364                &SyntaxSet::EMPTY,
365                true,
366            )
367            .into_iter()
368            .map(|seg| seg.raw().to_uppercase().into())
369            .collect();
370
371        if col_alias_names.is_empty() {
372            return false;
373        }
374
375        // Check if any *unqualified* reference (or one qualified with this
376        // alias) has an Object-level part matching a column alias name.
377        // Qualified references like `o.value` belong to table `o`, not to our
378        // alias, so they must not count as usage of the column alias list.
379        for reference in &select_info.reference_buffer {
380            let table_refs =
381                reference.extract_possible_references(ObjectReferenceLevel::Table, dialect_name);
382            if let Some(tbl) = table_refs.first() {
383                // Qualified reference — only count it if the qualifier is our
384                // own table alias.
385                if tbl.part.to_uppercase() != alias.ref_str.to_uppercase() {
386                    continue;
387                }
388            }
389            // Unqualified reference (no table part) or qualified with our alias.
390            for obj_ref in
391                reference.extract_possible_references(ObjectReferenceLevel::Object, dialect_name)
392            {
393                if col_alias_names.contains(&SmolStr::from(obj_ref.part.to_uppercase())) {
394                    return true;
395                }
396            }
397        }
398
399        false
400    }
401
402    fn has_function_alias_reference(
403        &self,
404        alias: &AliasInfo,
405        select_info: &SelectStatementColumnsAndTables,
406        dialect_name: DialectKind,
407    ) -> bool {
408        let Some(table_expression) = alias
409            .from_expression_element
410            .child(const { &SyntaxSet::single(SyntaxKind::TableExpression) })
411        else {
412            return false;
413        };
414
415        if table_expression
416            .child(const { &SyntaxSet::single(SyntaxKind::Function) })
417            .is_none()
418        {
419            return false;
420        }
421
422        let alias_name = self.alias_name(alias, dialect_name);
423
424        select_info.reference_buffer.iter().any(|reference| {
425            let references = reference.iter_raw_references();
426            if references.len() != 1 {
427                return false;
428            }
429
430            references
431                .first()
432                .and_then(|reference_part| reference_part.segments.first())
433                .is_some_and(|segment| {
434                    self.normalize_identifier(segment, dialect_name) == alias_name
435                })
436        })
437    }
438
439    fn report_unused_alias(&self, alias: &AliasInfo) -> LintResult {
440        let mut fixes = vec![LintFix::delete(alias.alias_expression.clone().unwrap())];
441
442        // Delete contiguous whitespace/meta immediately preceding the alias expression
443        // without allocating intermediate Segments collections.
444        if let Some(alias_idx) = alias
445            .from_expression_element
446            .segments()
447            .iter()
448            .position(|s| s == alias.alias_expression.as_ref().unwrap())
449        {
450            for seg in alias.from_expression_element.segments()[..alias_idx]
451                .iter()
452                .rev()
453                .take_while(|s| s.is_whitespace() || s.is_meta())
454            {
455                fixes.push(LintFix::delete(seg.clone()));
456            }
457        }
458
459        LintResult::new(
460            alias.segment.clone(),
461            fixes,
462            format!(
463                "Alias '{}' is never used in SELECT statement.",
464                self.display_alias_name(alias)
465            )
466            .into(),
467            None,
468        )
469    }
470
471    fn alias_name(&self, alias: &AliasInfo, dialect: DialectKind) -> SmolStr {
472        alias
473            .segment
474            .as_ref()
475            .map(|segment| self.normalize_identifier(segment, dialect))
476            .unwrap_or_else(|| self.normalize_identifier_str(&alias.ref_str, None, dialect))
477    }
478
479    fn display_alias_name(&self, alias: &AliasInfo) -> String {
480        alias
481            .segment
482            .as_ref()
483            .map(|segment| {
484                self.normalize_identifier_str(
485                    segment.raw(),
486                    Some(segment.get_type()),
487                    DialectKind::Ansi,
488                )
489            })
490            .unwrap_or_else(|| {
491                self.normalize_identifier_str(&alias.ref_str, None, DialectKind::Ansi)
492            })
493            .to_string()
494    }
495
496    fn normalize_identifier(&self, identifier: &ErasedSegment, dialect: DialectKind) -> SmolStr {
497        self.normalize_identifier_str(identifier.raw(), Some(identifier.get_type()), dialect)
498    }
499
500    fn normalize_identifier_str(
501        &self,
502        raw: &str,
503        syntax_kind: Option<SyntaxKind>,
504        dialect: DialectKind,
505    ) -> SmolStr {
506        let is_naked = syntax_kind.is_none_or(|kind| {
507            matches!(kind, SyntaxKind::Identifier | SyntaxKind::NakedIdentifier)
508        }) && !matches!(
509            raw.chars().next(),
510            Some('"') | Some('\'') | Some('`') | Some('[')
511        );
512        let mut normalized = if raw.starts_with('[') && raw.ends_with(']') && raw.len() >= 2 {
513            raw[1..raw.len() - 1].to_string()
514        } else if matches!(raw.chars().next(), Some('"') | Some('\'') | Some('`'))
515            && raw.len() >= 2
516            && raw.chars().next() == raw.chars().last()
517        {
518            let quote = raw.chars().next().unwrap();
519            raw[1..raw.len() - 1].replace(&format!("{quote}{quote}"), &quote.to_string())
520        } else {
521            raw.to_string()
522        };
523
524        match self.alias_case_check {
525            AliasCaseCheck::Dialect => {
526                if is_naked {
527                    normalized = match dialect {
528                        DialectKind::Postgres | DialectKind::Redshift => normalized.to_lowercase(),
529                        _ => normalized.to_uppercase(),
530                    };
531                }
532            }
533            AliasCaseCheck::CaseInsensitive => normalized = normalized.to_uppercase(),
534            AliasCaseCheck::QuotedCaseSensitiveNakedUpper => {
535                if is_naked {
536                    normalized = normalized.to_uppercase();
537                }
538            }
539            AliasCaseCheck::QuotedCaseSensitiveNakedLower => {
540                if is_naked {
541                    normalized = normalized.to_lowercase();
542                }
543            }
544            AliasCaseCheck::CaseSensitive => {}
545        }
546
547        normalized.into()
548    }
549
550    fn followed_by_qualify(&self, context: &RuleContext, alias: &AliasInfo) -> bool {
551        let Some(alias_expression) = &alias.alias_expression else {
552            return false;
553        };
554        let mut current_from_seen = false;
555
556        for seg in context.segment.segments() {
557            if alias_expression.get_end_loc() == seg.get_end_loc() {
558                current_from_seen = true;
559            } else if current_from_seen && !seg.is_code() {
560                continue;
561            } else if current_from_seen && seg.is_type(SyntaxKind::QualifyClause) {
562                return true;
563            } else if current_from_seen {
564                return false;
565            }
566        }
567
568        false
569    }
570}
571
572#[cfg(test)]
573mod tests {
574    use crate::core::config::FluffConfig;
575    use crate::core::linter::core::Linter;
576
577    const POSTGRES_JSON_ALIAS_REPRODUCER: &str = r#"with stanza as (
578    select
579        data -> 'name' as name,
580        data -> 'backup' -> (
581            jsonb_array_length(data -> 'backup') - 1
582        ) as last_backup,
583        data -> 'archive' -> (
584            jsonb_array_length(data -> 'archive') - 1
585        ) as current_archive
586    from jsonb_array_elements(monitor.pgbackrest_info()) as data
587)
588
589select
590    name,
591    to_timestamp(
592        (last_backup -> 'timestamp' ->> 'stop')::numeric
593    ) as last_successful_backup,
594    current_archive ->> 'max' as last_archived_wal
595from stanza;
596"#;
597
598    fn postgres_al05_linter() -> Linter {
599        let config = FluffConfig::from_source(
600            r#"
601[sqruff]
602rules = AL05
603dialect = postgres
604"#,
605            None,
606        );
607
608        Linter::new(config, None, None, true).unwrap()
609    }
610
611    #[test]
612    fn test_al05_postgres_json_operator_alias_is_used() {
613        let mut linter = postgres_al05_linter();
614        let linted = linter
615            .lint_string_wrapped(POSTGRES_JSON_ALIAS_REPRODUCER, false)
616            .unwrap();
617
618        assert_eq!(linted.violations(), &[]);
619    }
620
621    #[test]
622    fn test_al05_postgres_json_operator_fix_preserves_alias() {
623        let mut linter = postgres_al05_linter();
624        let linted = linter
625            .lint_string_wrapped(POSTGRES_JSON_ALIAS_REPRODUCER, true)
626            .unwrap();
627
628        assert_eq!(linted.fix_string(), POSTGRES_JSON_ALIAS_REPRODUCER);
629    }
630}