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