Skip to main content

flowscope_core/linter/rules/
st_011.rs

1//! LINT_ST_011: Unused joined source.
2//!
3//! Joined relations should be referenced outside of their own JOIN clause.
4
5use std::collections::HashSet;
6
7use crate::linter::rule::{LintContext, LintRule};
8use crate::types::{issue_codes, Issue};
9use sqlparser::ast::{
10    ConnectByKind, CreateView, Expr, FunctionArg, FunctionArgExpr, JoinOperator, NamedWindowExpr,
11    OrderByKind, Query, Select, SelectItem, SelectItemQualifiedWildcardKind, SetExpr, Statement,
12    TableFactor,
13};
14
15use super::semantic_helpers::{
16    collect_qualifier_prefixes_in_expr, count_reference_qualification_in_expr_excluding_aliases,
17    join_on_expr, select_projection_alias_set, table_factor_reference_name,
18};
19
20pub struct StructureUnusedJoin;
21
22impl LintRule for StructureUnusedJoin {
23    fn code(&self) -> &'static str {
24        issue_codes::LINT_ST_011
25    }
26
27    fn name(&self) -> &'static str {
28        "Structure unused join"
29    }
30
31    fn description(&self) -> &'static str {
32        "Joined table not referenced in query."
33    }
34
35    fn check(&self, statement: &Statement, ctx: &LintContext) -> Vec<Issue> {
36        let violations = unused_join_count_for_statement(statement);
37
38        (0..violations)
39            .map(|_| {
40                Issue::warning(issue_codes::LINT_ST_011, "Joined source appears unused.")
41                    .with_statement(ctx.statement_index)
42            })
43            .collect()
44    }
45}
46
47fn unused_join_count_for_statement(statement: &Statement) -> usize {
48    match statement {
49        Statement::Query(query) => unused_join_count_for_query(query),
50        Statement::Insert(insert) => insert
51            .source
52            .as_ref()
53            .map_or(0, |query| unused_join_count_for_query(query)),
54        Statement::CreateView(CreateView { query, .. }) => unused_join_count_for_query(query),
55        Statement::CreateTable(create) => create
56            .query
57            .as_ref()
58            .map_or(0, |query| unused_join_count_for_query(query)),
59        _ => 0,
60    }
61}
62
63fn unused_join_count_for_query(query: &Query) -> usize {
64    let mut total = 0usize;
65
66    if let Some(with) = &query.with {
67        for cte in &with.cte_tables {
68            total += unused_join_count_for_query(&cte.query);
69        }
70    }
71
72    let query_order_by_exprs = query_order_by_exprs(query);
73    total + unused_join_count_for_set_expr(&query.body, &query_order_by_exprs)
74}
75
76fn unused_join_count_for_set_expr(set_expr: &SetExpr, query_order_by_exprs: &[&Expr]) -> usize {
77    match set_expr {
78        SetExpr::Select(select) => {
79            let mut total = unused_join_count_for_select(select, query_order_by_exprs);
80
81            for table in &select.from {
82                total += unused_join_count_for_table_factor(&table.relation);
83                for join in &table.joins {
84                    total += unused_join_count_for_table_factor(&join.relation);
85                }
86            }
87
88            visit_non_join_select_expressions(select, &mut |expr| {
89                total += unused_join_count_for_expr_subqueries(expr);
90            });
91            visit_distinct_on_expressions(select, &mut |expr| {
92                total += unused_join_count_for_expr_subqueries(expr);
93            });
94            visit_named_window_expressions(select, &mut |expr| {
95                total += unused_join_count_for_expr_subqueries(expr);
96            });
97
98            total
99        }
100        SetExpr::Query(query) => unused_join_count_for_query(query),
101        SetExpr::SetOperation { left, right, .. } => {
102            unused_join_count_for_set_expr(left, &[]) + unused_join_count_for_set_expr(right, &[])
103        }
104        SetExpr::Insert(statement)
105        | SetExpr::Update(statement)
106        | SetExpr::Delete(statement)
107        | SetExpr::Merge(statement) => unused_join_count_for_statement(statement),
108        _ => 0,
109    }
110}
111
112fn query_order_by_exprs(query: &Query) -> Vec<&Expr> {
113    let Some(order_by) = &query.order_by else {
114        return Vec::new();
115    };
116
117    match &order_by.kind {
118        OrderByKind::Expressions(order_exprs) => {
119            order_exprs.iter().map(|item| &item.expr).collect()
120        }
121        _ => Vec::new(),
122    }
123}
124
125fn unused_join_count_for_select(select: &Select, query_order_by_exprs: &[&Expr]) -> usize {
126    if select.from.is_empty() {
127        return 0;
128    }
129
130    let mut joined_sources = joined_sources(select);
131    if joined_sources.is_empty() {
132        return 0;
133    }
134
135    let referenced_in_join_clauses = referenced_tables_in_join_clauses(select);
136    joined_sources.retain(|source| !referenced_in_join_clauses.contains(source));
137    if joined_sources.is_empty() {
138        return 0;
139    }
140
141    // SQLFluff ST11 parity: unqualified wildcard projection references
142    // all available sources in the select scope.
143    if select_has_unqualified_wildcard(select) {
144        return 0;
145    }
146
147    let aliases = select_projection_alias_set(select);
148    let mut used_prefixes = HashSet::new();
149    let mut unqualified_references = 0usize;
150
151    visit_non_join_select_expressions(select, &mut |expr| {
152        collect_qualifier_prefixes_in_expr(expr, &mut used_prefixes);
153        let (_, unqualified) =
154            count_reference_qualification_in_expr_excluding_aliases(expr, &aliases);
155        unqualified_references += unqualified;
156    });
157    visit_distinct_on_expressions(select, &mut |expr| {
158        collect_qualifier_prefixes_in_expr(expr, &mut used_prefixes);
159        let (_, unqualified) =
160            count_reference_qualification_in_expr_excluding_aliases(expr, &aliases);
161        unqualified_references += unqualified;
162    });
163    visit_named_window_expressions(select, &mut |expr| {
164        collect_qualifier_prefixes_in_expr(expr, &mut used_prefixes);
165        let (_, unqualified) =
166            count_reference_qualification_in_expr_excluding_aliases(expr, &aliases);
167        unqualified_references += unqualified;
168    });
169
170    for expr in query_order_by_exprs {
171        collect_qualifier_prefixes_in_expr(expr, &mut used_prefixes);
172        let (_, unqualified) =
173            count_reference_qualification_in_expr_excluding_aliases(expr, &aliases);
174        unqualified_references += unqualified;
175    }
176
177    // Match SQLFluff ST11 behavior: if unqualified references exist,
178    // this rule defers until reference qualification issues are resolved.
179    if unqualified_references > 0 {
180        return 0;
181    }
182
183    collect_projection_wildcard_prefixes(select, &mut used_prefixes);
184    collect_join_relation_reference_prefixes(select, &mut used_prefixes);
185
186    joined_sources
187        .iter()
188        .filter(|source| !used_prefixes.contains(*source))
189        .count()
190}
191
192fn unused_join_count_for_table_factor(table_factor: &TableFactor) -> usize {
193    match table_factor {
194        TableFactor::Derived { subquery, .. } => unused_join_count_for_query(subquery),
195        TableFactor::NestedJoin {
196            table_with_joins, ..
197        } => {
198            let mut total = unused_join_count_for_table_factor(&table_with_joins.relation);
199            for join in &table_with_joins.joins {
200                total += unused_join_count_for_table_factor(&join.relation);
201                if let Some(on_expr) = join_on_expr(&join.join_operator) {
202                    total += unused_join_count_for_expr_subqueries(on_expr);
203                }
204            }
205            total
206        }
207        TableFactor::Pivot {
208            table,
209            aggregate_functions,
210            value_column,
211            default_on_null,
212            ..
213        } => {
214            let mut total = unused_join_count_for_table_factor(table);
215            for expr_with_alias in aggregate_functions {
216                total += unused_join_count_for_expr_subqueries(&expr_with_alias.expr);
217            }
218            for expr in value_column {
219                total += unused_join_count_for_expr_subqueries(expr);
220            }
221            if let Some(expr) = default_on_null {
222                total += unused_join_count_for_expr_subqueries(expr);
223            }
224            total
225        }
226        TableFactor::Unpivot {
227            table,
228            value,
229            columns,
230            ..
231        } => {
232            let mut total = unused_join_count_for_table_factor(table);
233            total += unused_join_count_for_expr_subqueries(value);
234            for expr_with_alias in columns {
235                total += unused_join_count_for_expr_subqueries(&expr_with_alias.expr);
236            }
237            total
238        }
239        TableFactor::MatchRecognize {
240            table,
241            partition_by,
242            order_by,
243            measures,
244            ..
245        } => {
246            let mut total = unused_join_count_for_table_factor(table);
247            for expr in partition_by {
248                total += unused_join_count_for_expr_subqueries(expr);
249            }
250            for order in order_by {
251                total += unused_join_count_for_expr_subqueries(&order.expr);
252            }
253            for measure in measures {
254                total += unused_join_count_for_expr_subqueries(&measure.expr);
255            }
256            total
257        }
258        TableFactor::TableFunction { expr, .. } => unused_join_count_for_expr_subqueries(expr),
259        TableFactor::Function { args, .. } => args
260            .iter()
261            .map(unused_join_count_for_function_arg)
262            .sum::<usize>(),
263        TableFactor::UNNEST { array_exprs, .. } => array_exprs
264            .iter()
265            .map(unused_join_count_for_expr_subqueries)
266            .sum::<usize>(),
267        TableFactor::JsonTable { json_expr, .. } | TableFactor::OpenJsonTable { json_expr, .. } => {
268            unused_join_count_for_expr_subqueries(json_expr)
269        }
270        TableFactor::XmlTable { row_expression, .. } => {
271            unused_join_count_for_expr_subqueries(row_expression)
272        }
273        TableFactor::Table { .. } | TableFactor::SemanticView { .. } => 0,
274    }
275}
276
277fn unused_join_count_for_function_arg(arg: &FunctionArg) -> usize {
278    match arg {
279        FunctionArg::Unnamed(FunctionArgExpr::Expr(expr))
280        | FunctionArg::Named {
281            arg: FunctionArgExpr::Expr(expr),
282            ..
283        } => unused_join_count_for_expr_subqueries(expr),
284        _ => 0,
285    }
286}
287
288fn unused_join_count_for_expr_subqueries(expr: &Expr) -> usize {
289    match expr {
290        Expr::Subquery(query)
291        | Expr::Exists {
292            subquery: query, ..
293        } => unused_join_count_for_query(query),
294        Expr::InSubquery {
295            expr: inner,
296            subquery,
297            ..
298        } => unused_join_count_for_expr_subqueries(inner) + unused_join_count_for_query(subquery),
299        Expr::BinaryOp { left, right, .. }
300        | Expr::AnyOp { left, right, .. }
301        | Expr::AllOp { left, right, .. } => {
302            unused_join_count_for_expr_subqueries(left)
303                + unused_join_count_for_expr_subqueries(right)
304        }
305        Expr::UnaryOp { expr: inner, .. }
306        | Expr::Nested(inner)
307        | Expr::IsNull(inner)
308        | Expr::IsNotNull(inner)
309        | Expr::Cast { expr: inner, .. } => unused_join_count_for_expr_subqueries(inner),
310        Expr::InList { expr, list, .. } => {
311            unused_join_count_for_expr_subqueries(expr)
312                + list
313                    .iter()
314                    .map(unused_join_count_for_expr_subqueries)
315                    .sum::<usize>()
316        }
317        Expr::Between {
318            expr, low, high, ..
319        } => {
320            unused_join_count_for_expr_subqueries(expr)
321                + unused_join_count_for_expr_subqueries(low)
322                + unused_join_count_for_expr_subqueries(high)
323        }
324        Expr::Case {
325            operand,
326            conditions,
327            else_result,
328            ..
329        } => {
330            let operand_count = operand
331                .as_ref()
332                .map_or(0, |expr| unused_join_count_for_expr_subqueries(expr));
333            let condition_count = conditions
334                .iter()
335                .map(|when| {
336                    unused_join_count_for_expr_subqueries(&when.condition)
337                        + unused_join_count_for_expr_subqueries(&when.result)
338                })
339                .sum::<usize>();
340            let else_count = else_result
341                .as_ref()
342                .map_or(0, |expr| unused_join_count_for_expr_subqueries(expr));
343            operand_count + condition_count + else_count
344        }
345        Expr::Function(function) => {
346            let args_count =
347                if let sqlparser::ast::FunctionArguments::List(arguments) = &function.args {
348                    arguments
349                        .args
350                        .iter()
351                        .map(unused_join_count_for_function_arg)
352                        .sum::<usize>()
353                } else {
354                    0
355                };
356            let filter_count = function
357                .filter
358                .as_ref()
359                .map_or(0, |expr| unused_join_count_for_expr_subqueries(expr));
360            let within_group_count = function
361                .within_group
362                .iter()
363                .map(|order| unused_join_count_for_expr_subqueries(&order.expr))
364                .sum::<usize>();
365            let window_count = match &function.over {
366                Some(sqlparser::ast::WindowType::WindowSpec(spec)) => {
367                    spec.partition_by
368                        .iter()
369                        .map(unused_join_count_for_expr_subqueries)
370                        .sum::<usize>()
371                        + spec
372                            .order_by
373                            .iter()
374                            .map(|order| unused_join_count_for_expr_subqueries(&order.expr))
375                            .sum::<usize>()
376                }
377                _ => 0,
378            };
379            args_count + filter_count + within_group_count + window_count
380        }
381        _ => 0,
382    }
383}
384
385fn select_has_unqualified_wildcard(select: &Select) -> bool {
386    select
387        .projection
388        .iter()
389        .any(|item| matches!(item, SelectItem::Wildcard(_)))
390}
391
392fn joined_sources(select: &Select) -> HashSet<String> {
393    let mut joined_sources = HashSet::new();
394
395    for table in &select.from {
396        for join in &table.joins {
397            if !is_tracked_join(&join.join_operator) {
398                continue;
399            }
400
401            if let Some(name) = table_factor_reference_name(&join.relation) {
402                joined_sources.insert(name);
403            }
404        }
405    }
406
407    joined_sources
408}
409
410fn referenced_tables_in_join_clauses(select: &Select) -> HashSet<String> {
411    let mut referenced = HashSet::new();
412
413    for table in &select.from {
414        for join in &table.joins {
415            let self_ref = table_factor_reference_name(&join.relation);
416
417            if let Some(on_expr) = join_on_expr(&join.join_operator) {
418                let mut refs = HashSet::new();
419                collect_qualifier_prefixes_in_expr(on_expr, &mut refs);
420                for table_ref in refs {
421                    if self_ref.as_deref() != Some(table_ref.as_str()) {
422                        referenced.insert(table_ref);
423                    }
424                }
425            }
426        }
427    }
428
429    referenced
430}
431
432fn is_tracked_join(operator: &JoinOperator) -> bool {
433    matches!(
434        operator,
435        JoinOperator::Left(_)
436            | JoinOperator::LeftOuter(_)
437            | JoinOperator::Right(_)
438            | JoinOperator::RightOuter(_)
439            | JoinOperator::FullOuter(_)
440    )
441}
442
443fn visit_non_join_select_expressions<F: FnMut(&sqlparser::ast::Expr)>(
444    select: &Select,
445    visitor: &mut F,
446) {
447    for item in &select.projection {
448        if let sqlparser::ast::SelectItem::UnnamedExpr(expr)
449        | sqlparser::ast::SelectItem::ExprWithAlias { expr, .. } = item
450        {
451            visitor(expr);
452        }
453    }
454
455    if let Some(prewhere) = &select.prewhere {
456        visitor(prewhere);
457    }
458
459    if let Some(selection) = &select.selection {
460        visitor(selection);
461    }
462
463    if let sqlparser::ast::GroupByExpr::Expressions(exprs, _) = &select.group_by {
464        for expr in exprs {
465            visitor(expr);
466        }
467    }
468
469    for expr in &select.cluster_by {
470        visitor(expr);
471    }
472
473    for expr in &select.distribute_by {
474        visitor(expr);
475    }
476
477    for lateral_view in &select.lateral_views {
478        visitor(&lateral_view.lateral_view);
479    }
480
481    if let Some(having) = &select.having {
482        visitor(having);
483    }
484
485    if let Some(qualify) = &select.qualify {
486        visitor(qualify);
487    }
488
489    for connect_by_kind in &select.connect_by {
490        match connect_by_kind {
491            ConnectByKind::ConnectBy { relationships, .. } => {
492                for relationship in relationships {
493                    visitor(relationship);
494                }
495            }
496            ConnectByKind::StartWith { condition, .. } => {
497                visitor(condition);
498            }
499        }
500    }
501
502    for sort in &select.sort_by {
503        visitor(&sort.expr);
504    }
505}
506
507fn visit_distinct_on_expressions<F: FnMut(&sqlparser::ast::Expr)>(
508    select: &Select,
509    visitor: &mut F,
510) {
511    if let Some(sqlparser::ast::Distinct::On(expressions)) = &select.distinct {
512        for expr in expressions {
513            visitor(expr);
514        }
515    }
516}
517
518fn visit_named_window_expressions<F: FnMut(&sqlparser::ast::Expr)>(
519    select: &Select,
520    visitor: &mut F,
521) {
522    for named_window in &select.named_window {
523        if let NamedWindowExpr::WindowSpec(spec) = &named_window.1 {
524            for expr in &spec.partition_by {
525                visitor(expr);
526            }
527            for order in &spec.order_by {
528                visitor(&order.expr);
529            }
530        }
531    }
532}
533
534fn collect_projection_wildcard_prefixes(select: &Select, prefixes: &mut HashSet<String>) {
535    for item in &select.projection {
536        if let SelectItem::QualifiedWildcard(kind, _) = item {
537            match kind {
538                SelectItemQualifiedWildcardKind::ObjectName(name) => {
539                    if let Some(last) = name.0.last().and_then(|part| part.as_ident()) {
540                        prefixes.insert(last.value.to_ascii_uppercase());
541                    }
542                }
543                SelectItemQualifiedWildcardKind::Expr(expr) => match expr {
544                    Expr::Identifier(identifier) => {
545                        prefixes.insert(identifier.value.to_ascii_uppercase());
546                    }
547                    Expr::CompoundIdentifier(parts) if parts.len() > 1 => {
548                        if let Some(first) = parts.first() {
549                            prefixes.insert(first.value.to_ascii_uppercase());
550                        }
551                    }
552                    _ => collect_qualifier_prefixes_in_expr(expr, prefixes),
553                },
554            }
555        }
556    }
557}
558
559fn collect_join_relation_reference_prefixes(select: &Select, prefixes: &mut HashSet<String>) {
560    for table in &select.from {
561        for join in &table.joins {
562            collect_table_factor_reference_prefixes(&join.relation, prefixes);
563        }
564    }
565}
566
567fn collect_table_factor_reference_prefixes(
568    table_factor: &TableFactor,
569    prefixes: &mut HashSet<String>,
570) {
571    match table_factor {
572        TableFactor::Table { .. } => {}
573        TableFactor::Derived { .. } => {}
574        TableFactor::TableFunction { expr, .. } => {
575            collect_qualifier_prefixes_in_expr(expr, prefixes);
576        }
577        TableFactor::Function { args, .. } => {
578            for arg in args {
579                collect_function_arg_prefixes(arg, prefixes);
580            }
581        }
582        TableFactor::UNNEST { array_exprs, .. } => {
583            for expr in array_exprs {
584                collect_qualifier_prefixes_in_expr(expr, prefixes);
585            }
586        }
587        TableFactor::JsonTable { json_expr, .. } | TableFactor::OpenJsonTable { json_expr, .. } => {
588            collect_qualifier_prefixes_in_expr(json_expr, prefixes);
589        }
590        TableFactor::NestedJoin {
591            table_with_joins, ..
592        } => {
593            collect_table_factor_reference_prefixes(&table_with_joins.relation, prefixes);
594            for join in &table_with_joins.joins {
595                collect_table_factor_reference_prefixes(&join.relation, prefixes);
596            }
597        }
598        TableFactor::Pivot {
599            table,
600            aggregate_functions,
601            value_column,
602            default_on_null,
603            ..
604        } => {
605            collect_table_factor_reference_prefixes(table, prefixes);
606            for expr_with_alias in aggregate_functions {
607                collect_qualifier_prefixes_in_expr(&expr_with_alias.expr, prefixes);
608            }
609            for expr in value_column {
610                collect_qualifier_prefixes_in_expr(expr, prefixes);
611            }
612            if let Some(expr) = default_on_null {
613                collect_qualifier_prefixes_in_expr(expr, prefixes);
614            }
615        }
616        TableFactor::Unpivot {
617            table,
618            value,
619            columns,
620            ..
621        } => {
622            collect_table_factor_reference_prefixes(table, prefixes);
623            collect_qualifier_prefixes_in_expr(value, prefixes);
624            for expr_with_alias in columns {
625                collect_qualifier_prefixes_in_expr(&expr_with_alias.expr, prefixes);
626            }
627        }
628        TableFactor::MatchRecognize {
629            table,
630            partition_by,
631            order_by,
632            measures,
633            ..
634        } => {
635            collect_table_factor_reference_prefixes(table, prefixes);
636            for expr in partition_by {
637                collect_qualifier_prefixes_in_expr(expr, prefixes);
638            }
639            for order in order_by {
640                collect_qualifier_prefixes_in_expr(&order.expr, prefixes);
641            }
642            for measure in measures {
643                collect_qualifier_prefixes_in_expr(&measure.expr, prefixes);
644            }
645        }
646        TableFactor::XmlTable { row_expression, .. } => {
647            collect_qualifier_prefixes_in_expr(row_expression, prefixes);
648        }
649        TableFactor::SemanticView { .. } => {}
650    }
651}
652
653fn collect_function_arg_prefixes(arg: &FunctionArg, prefixes: &mut HashSet<String>) {
654    match arg {
655        FunctionArg::Unnamed(FunctionArgExpr::Expr(expr))
656        | FunctionArg::Named {
657            arg: FunctionArgExpr::Expr(expr),
658            ..
659        } => collect_qualifier_prefixes_in_expr(expr, prefixes),
660        _ => {}
661    }
662}
663
664#[cfg(test)]
665mod tests {
666    use super::*;
667    use crate::{parser::parse_sql_with_dialect, types::Dialect};
668
669    fn run(sql: &str) -> Vec<Issue> {
670        run_in_dialect(sql, Dialect::Generic)
671    }
672
673    fn run_in_dialect(sql: &str, dialect: Dialect) -> Vec<Issue> {
674        let statements = parse_sql_with_dialect(sql, dialect).expect("parse");
675        let rule = StructureUnusedJoin;
676        statements
677            .iter()
678            .enumerate()
679            .flat_map(|(index, statement)| {
680                rule.check(
681                    statement,
682                    &LintContext {
683                        sql,
684                        statement_range: 0..sql.len(),
685                        statement_index: index,
686                    },
687                )
688            })
689            .collect()
690    }
691
692    // --- Edge cases adopted from sqlfluff ST11 ---
693
694    #[test]
695    fn flags_unused_outer_joined_source() {
696        let issues = run("select 1 from b left join c on b.x = c.x");
697        assert_eq!(issues.len(), 1);
698        assert_eq!(issues[0].code, issue_codes::LINT_ST_011);
699    }
700
701    #[test]
702    fn allows_single_table_statement() {
703        let issues = run("select 1 from foo");
704        assert!(issues.is_empty());
705    }
706
707    #[test]
708    fn allows_single_values_table_statement() {
709        let issues = run("select 1 from (values (1, 'one'), (2, 'two'))");
710        assert!(issues.is_empty());
711    }
712
713    #[test]
714    fn allows_inner_join_when_joined_source_unreferenced() {
715        let issues = run("select a.* from a inner join b using(x)");
716        assert!(issues.is_empty());
717    }
718
719    #[test]
720    fn allows_implicit_inner_join_when_joined_source_unreferenced() {
721        let issues = run("select a.* from a join b using(x)");
722        assert!(issues.is_empty());
723    }
724
725    #[test]
726    fn flags_unreferenced_right_joined_source() {
727        let issues = run("select a.* from a right join d using(x)");
728        assert_eq!(issues.len(), 1);
729        assert_eq!(issues[0].code, issue_codes::LINT_ST_011);
730    }
731
732    #[test]
733    fn defers_when_unqualified_references_exist() {
734        let issues = run("select a from b left join c using(d)");
735        assert!(issues.is_empty());
736    }
737
738    #[test]
739    fn allows_outer_join_when_joined_source_is_referenced() {
740        let issues = run("select widget.id, inventor.id from widget left join inventor on widget.inventor_id = inventor.id");
741        assert!(issues.is_empty());
742    }
743
744    #[test]
745    fn allows_outer_join_when_joined_source_only_referenced_in_query_order_by() {
746        let issues = run("select a.id from a left join b on a.id = b.id order by b.id");
747        assert!(issues.is_empty());
748    }
749
750    #[test]
751    fn does_not_flag_base_from_source_as_unused_with_using_join() {
752        let issues = run("select c.id from b left join c using(id)");
753        assert!(issues.is_empty());
754    }
755
756    #[test]
757    fn allows_unqualified_wildcard_projection() {
758        let issues = run("select * from a left join b on a.id = b.id");
759        assert!(issues.is_empty());
760    }
761
762    #[test]
763    fn detects_unused_join_in_subquery_scope() {
764        let issues = run(
765            "SELECT a.col1 FROM a LEFT JOIN b ON a.id = b.a_id WHERE a.some_column IN (SELECT c.some_column FROM c WHERE c.other_column = a.col)",
766        );
767        assert_eq!(issues.len(), 1);
768    }
769
770    #[test]
771    fn allows_join_reference_inside_subquery() {
772        let issues = run(
773            "SELECT a.col1 FROM a LEFT JOIN b ON a.id = b.a_id WHERE a.some_column IN (SELECT c.some_column FROM c WHERE c.other_column = b.col)",
774        );
775        assert!(issues.is_empty());
776    }
777
778    #[test]
779    fn flags_unused_outer_join_inside_derived_subquery() {
780        let issues =
781            run("SELECT * FROM (SELECT t1.col1 FROM db1.t1 LEFT JOIN t2 ON t1.id = t2.id)");
782        assert_eq!(issues.len(), 1);
783        assert_eq!(issues[0].code, issue_codes::LINT_ST_011);
784    }
785
786    #[test]
787    fn allows_outer_join_table_referenced_by_another_join_condition() {
788        let issues =
789            run("SELECT a.id FROM a LEFT JOIN b ON a.id = b.a_id LEFT JOIN c ON b.c_id = c.id");
790        assert_eq!(issues.len(), 1);
791        assert_eq!(issues[0].code, issue_codes::LINT_ST_011);
792    }
793
794    #[test]
795    fn flags_unused_outer_join_in_multi_root_from_clause() {
796        let issues = run("SELECT a.id FROM a, b LEFT JOIN c ON b.id = c.id");
797        assert_eq!(issues.len(), 1);
798        assert_eq!(issues[0].code, issue_codes::LINT_ST_011);
799    }
800
801    #[test]
802    fn allows_used_outer_join_in_multi_root_from_clause() {
803        let issues = run("SELECT c.id FROM a, b LEFT JOIN c ON b.id = c.id");
804        assert!(issues.is_empty());
805    }
806
807    #[test]
808    fn allows_outer_join_source_referenced_by_later_unnest_join_relation() {
809        let issues = run(
810            "SELECT ft.id, n.generic_field FROM fact_table AS ft LEFT JOIN UNNEST(ft.generic_array) AS g LEFT JOIN UNNEST(g.nested_array) AS n",
811        );
812        assert!(issues.is_empty());
813    }
814
815    #[test]
816    fn allows_outer_join_source_referenced_in_named_window_clause() {
817        let issues = run(
818            "SELECT sum(a.value) OVER w FROM a LEFT JOIN b ON a.id = b.id WINDOW w AS (PARTITION BY b.group_key)",
819        );
820        assert!(issues.is_empty());
821    }
822
823    #[test]
824    fn defers_when_named_window_clause_has_unqualified_reference() {
825        let issues = run(
826            "SELECT sum(a.value) OVER w FROM a LEFT JOIN b ON a.id = b.id WINDOW w AS (PARTITION BY group_key)",
827        );
828        assert!(issues.is_empty());
829    }
830
831    #[test]
832    fn allows_outer_join_source_referenced_in_distinct_on_clause() {
833        let issues = run("SELECT DISTINCT ON (b.id) a.id FROM a LEFT JOIN b ON a.id = b.id");
834        assert!(issues.is_empty());
835    }
836
837    #[test]
838    fn defers_when_distinct_on_clause_has_unqualified_reference() {
839        let issues = run("SELECT DISTINCT ON (id) a.id FROM a LEFT JOIN b ON a.id = b.id");
840        assert!(issues.is_empty());
841    }
842
843    #[test]
844    fn allows_outer_join_source_referenced_in_cluster_by_clause() {
845        let issues = run("SELECT a.id FROM a LEFT JOIN b ON a.id = b.id CLUSTER BY b.id");
846        assert!(issues.is_empty());
847    }
848
849    #[test]
850    fn allows_outer_join_source_referenced_in_distribute_by_clause() {
851        let issues = run("SELECT a.id FROM a LEFT JOIN b ON a.id = b.id DISTRIBUTE BY b.id");
852        assert!(issues.is_empty());
853    }
854
855    #[test]
856    fn allows_outer_join_source_referenced_with_mysql_backtick_quoted_table_name() {
857        let issues = run_in_dialect(
858            "SELECT `test`.one, `test-2`.two FROM `test` LEFT JOIN `test-2` ON `test`.id = `test-2`.id",
859            Dialect::Mysql,
860        );
861        assert!(issues.is_empty());
862    }
863
864    #[test]
865    fn allows_outer_join_source_referenced_with_mssql_bracket_quoted_table_name() {
866        let issues = run_in_dialect(
867            "SELECT [test].one, [test-2].two FROM [test] LEFT JOIN [test-2] ON [test].id = [test-2].id",
868            Dialect::Mssql,
869        );
870        assert!(issues.is_empty());
871    }
872
873    #[test]
874    fn allows_snowflake_qualified_wildcard_exclude_for_joined_source() {
875        let issues = run_in_dialect(
876            "select \
877                simulation_source_data_reference.*, \
878                sourcings.* exclude sourcing_job_id \
879             from simulation_source_data_reference \
880             left join sourcings \
881                 on simulation_source_data_reference.sourcing_job_id = sourcings.sourcing_job_id",
882            Dialect::Snowflake,
883        );
884        assert!(issues.is_empty());
885    }
886
887    #[test]
888    fn allows_outer_join_source_referenced_in_hive_lateral_view_clause() {
889        let issues = run_in_dialect(
890            "SELECT a.id FROM a LEFT JOIN b ON a.id = b.id LATERAL VIEW explode(b.items) lv AS item",
891            Dialect::Hive,
892        );
893        assert!(issues.is_empty());
894    }
895
896    #[test]
897    fn defers_when_hive_lateral_view_clause_has_unqualified_reference() {
898        let issues = run_in_dialect(
899            "SELECT a.id FROM a LEFT JOIN b ON a.id = b.id LATERAL VIEW explode(items) lv AS item",
900            Dialect::Hive,
901        );
902        assert!(issues.is_empty());
903    }
904
905    #[test]
906    fn allows_outer_join_source_referenced_in_connect_by_clause() {
907        let issues = run_in_dialect(
908            "SELECT a.id FROM a LEFT JOIN b ON a.id = b.id START WITH b.id IS NOT NULL CONNECT BY PRIOR a.id = b.id",
909            Dialect::Snowflake,
910        );
911        assert!(issues.is_empty());
912    }
913
914    #[test]
915    fn defers_when_connect_by_clause_has_unqualified_reference() {
916        let issues = run_in_dialect(
917            "SELECT a.id FROM a LEFT JOIN b ON a.id = b.id START WITH id IS NOT NULL CONNECT BY PRIOR a.id = b.id",
918            Dialect::Snowflake,
919        );
920        assert!(issues.is_empty());
921    }
922}