use crate::linter::rule::{LintContext, LintRule};
use crate::types::{issue_codes, Issue};
use sqlparser::ast::*;
use std::collections::HashSet;
pub struct UnusedCte;
impl LintRule for UnusedCte {
fn code(&self) -> &'static str {
issue_codes::LINT_ST_003
}
fn name(&self) -> &'static str {
"Unused CTE"
}
fn description(&self) -> &'static str {
"Query defines a CTE (common-table expression) but does not use it."
}
fn check(&self, stmt: &Statement, ctx: &LintContext) -> Vec<Issue> {
let query = match stmt {
Statement::Query(q) => q,
Statement::Insert(ins) => {
if let Some(ref source) = ins.source {
source
} else {
return Vec::new();
}
}
Statement::CreateView(CreateView { query, .. }) => query,
Statement::CreateTable(create) => {
if let Some(ref q) = create.query {
q
} else {
return Vec::new();
}
}
Statement::Delete(delete) => {
let mut issues = Vec::new();
check_delete_for_nested_ctes(delete, ctx, &mut issues);
return issues;
}
_ => return Vec::new(),
};
let mut issues = Vec::new();
check_query_unused_ctes(query, ctx, &mut issues);
issues
}
}
fn check_query_unused_ctes(query: &Query, ctx: &LintContext, issues: &mut Vec<Issue>) {
let with = match &query.with {
Some(w) => w,
None => {
check_set_expr_for_nested_ctes(&query.body, ctx, issues);
return;
}
};
let mut referenced = HashSet::new();
collect_table_refs(&query.body, &mut referenced);
if let Some(order_by) = &query.order_by {
collect_order_by_refs(order_by, &mut referenced);
}
for (i, cte) in with.cte_tables.iter().enumerate() {
let mut cte_refs = HashSet::new();
collect_query_refs(&cte.query, &mut cte_refs);
for later_cte in &with.cte_tables[i + 1..] {
collect_query_refs(&later_cte.query, &mut cte_refs);
}
referenced.extend(cte_refs);
}
for (i, cte) in with.cte_tables.iter().enumerate() {
let name_upper = cte.alias.name.value.to_uppercase();
if !referenced.contains(&name_upper) {
let referenced_by_later = with.cte_tables[i + 1..].iter().any(|later| {
let mut refs = HashSet::new();
collect_query_refs(&later.query, &mut refs);
refs.contains(&name_upper)
});
if referenced_by_later {
continue;
}
let span = find_cte_name_span(&cte.alias.name, ctx);
let mut issue = Issue::warning(
issue_codes::LINT_ST_003,
format!(
"CTE '{}' is defined but never referenced.",
cte.alias.name.value
),
)
.with_statement(ctx.statement_index);
if let Some(s) = span {
issue = issue.with_span(s);
}
issues.push(issue);
}
check_query_unused_ctes(&cte.query, ctx, issues);
}
check_set_expr_for_nested_ctes(&query.body, ctx, issues);
}
fn check_set_expr_for_nested_ctes(expr: &SetExpr, ctx: &LintContext, issues: &mut Vec<Issue>) {
match expr {
SetExpr::Select(select) => {
for item in &select.from {
check_relation_for_nested_ctes(&item.relation, ctx, issues);
for join in &item.joins {
check_relation_for_nested_ctes(&join.relation, ctx, issues);
}
}
for item in &select.projection {
if let SelectItem::UnnamedExpr(e) | SelectItem::ExprWithAlias { expr: e, .. } = item
{
check_expr_for_nested_ctes(e, ctx, issues);
}
}
if let Some(sel) = &select.selection {
check_expr_for_nested_ctes(sel, ctx, issues);
}
}
SetExpr::Query(q) => check_query_unused_ctes(q, ctx, issues),
SetExpr::SetOperation { left, right, .. } => {
check_set_expr_for_nested_ctes(left, ctx, issues);
check_set_expr_for_nested_ctes(right, ctx, issues);
}
_ => {}
}
}
fn check_delete_for_nested_ctes(delete: &Delete, ctx: &LintContext, issues: &mut Vec<Issue>) {
if let Some(using) = &delete.using {
for twj in using {
check_relation_for_nested_ctes(&twj.relation, ctx, issues);
for join in &twj.joins {
check_relation_for_nested_ctes(&join.relation, ctx, issues);
}
}
}
let from_tables = match &delete.from {
FromTable::WithFromKeyword(tables) | FromTable::WithoutKeyword(tables) => tables,
};
for twj in from_tables {
check_relation_for_nested_ctes(&twj.relation, ctx, issues);
for join in &twj.joins {
check_relation_for_nested_ctes(&join.relation, ctx, issues);
}
}
}
fn check_relation_for_nested_ctes(
relation: &TableFactor,
ctx: &LintContext,
issues: &mut Vec<Issue>,
) {
if let TableFactor::Derived { subquery, .. } = relation {
check_query_unused_ctes(subquery, ctx, issues);
}
}
fn check_expr_for_nested_ctes(expr: &Expr, ctx: &LintContext, issues: &mut Vec<Issue>) {
match expr {
Expr::Subquery(q) | Expr::Exists { subquery: q, .. } => {
check_query_unused_ctes(q, ctx, issues);
}
Expr::InSubquery { subquery, expr, .. } => {
check_query_unused_ctes(subquery, ctx, issues);
check_expr_for_nested_ctes(expr, ctx, issues);
}
Expr::BinaryOp { left, right, .. } => {
check_expr_for_nested_ctes(left, ctx, issues);
check_expr_for_nested_ctes(right, ctx, issues);
}
Expr::Nested(inner) => check_expr_for_nested_ctes(inner, ctx, issues),
_ => {}
}
}
fn collect_query_refs(query: &Query, refs: &mut HashSet<String>) {
if let Some(w) = &query.with {
for cte in &w.cte_tables {
collect_query_refs(&cte.query, refs);
}
}
collect_table_refs(&query.body, refs);
if let Some(order_by) = &query.order_by {
collect_order_by_refs(order_by, refs);
}
}
fn collect_statement_refs(stmt: &Statement, refs: &mut HashSet<String>) {
match stmt {
Statement::Query(query) => collect_query_refs(query, refs),
Statement::Insert(insert) => {
if let Some(source) = &insert.source {
collect_query_refs(source, refs);
}
}
Statement::CreateView(CreateView { query, .. }) => collect_query_refs(query, refs),
Statement::CreateTable(create) => {
if let Some(query) = &create.query {
collect_query_refs(query, refs);
}
}
Statement::Update(Update {
table,
from,
selection,
..
}) => {
collect_relation_refs(&table.relation, refs);
for join in &table.joins {
collect_relation_refs(&join.relation, refs);
collect_join_constraint_refs(&join.join_operator, refs);
}
if let Some(from_kind) = from {
let tables = match from_kind {
UpdateTableFromKind::BeforeSet(t) | UpdateTableFromKind::AfterSet(t) => t,
};
for twj in tables {
collect_relation_refs(&twj.relation, refs);
for join in &twj.joins {
collect_relation_refs(&join.relation, refs);
collect_join_constraint_refs(&join.join_operator, refs);
}
}
}
if let Some(sel) = selection {
collect_expr_table_refs(sel, refs);
}
}
Statement::Delete(delete) => {
if let Some(using) = &delete.using {
for twj in using {
collect_relation_refs(&twj.relation, refs);
for join in &twj.joins {
collect_relation_refs(&join.relation, refs);
collect_join_constraint_refs(&join.join_operator, refs);
}
}
}
if let Some(sel) = &delete.selection {
collect_expr_table_refs(sel, refs);
}
}
_ => {}
}
}
fn collect_table_refs(expr: &SetExpr, refs: &mut HashSet<String>) {
match expr {
SetExpr::Select(select) => {
for item in &select.from {
collect_relation_refs(&item.relation, refs);
for join in &item.joins {
collect_relation_refs(&join.relation, refs);
collect_join_constraint_refs(&join.join_operator, refs);
}
}
for item in &select.projection {
if let SelectItem::UnnamedExpr(expr) | SelectItem::ExprWithAlias { expr, .. } = item
{
collect_expr_table_refs(expr, refs);
}
}
if let Some(prewhere) = &select.prewhere {
collect_expr_table_refs(prewhere, refs);
}
if let Some(ref selection) = select.selection {
collect_expr_table_refs(selection, refs);
}
if let Some(ref having) = select.having {
collect_expr_table_refs(having, refs);
}
if let Some(ref qualify) = select.qualify {
collect_expr_table_refs(qualify, refs);
}
if let GroupByExpr::Expressions(exprs, _) = &select.group_by {
for expr in exprs {
collect_expr_table_refs(expr, refs);
}
}
for sort_expr in &select.sort_by {
collect_expr_table_refs(&sort_expr.expr, refs);
}
}
SetExpr::Query(q) => {
collect_query_refs(q, refs);
if let Some(w) = &q.with {
for cte in &w.cte_tables {
collect_query_refs(&cte.query, refs);
}
}
}
SetExpr::SetOperation { left, right, .. } => {
collect_table_refs(left, refs);
collect_table_refs(right, refs);
}
SetExpr::Insert(stmt)
| SetExpr::Update(stmt)
| SetExpr::Delete(stmt)
| SetExpr::Merge(stmt) => {
collect_statement_refs(stmt, refs);
}
_ => {}
}
}
fn collect_expr_table_refs(expr: &Expr, refs: &mut HashSet<String>) {
match expr {
Expr::InSubquery { subquery, expr, .. } => {
collect_query_refs(subquery, refs);
if let Some(w) = &subquery.with {
for cte in &w.cte_tables {
collect_query_refs(&cte.query, refs);
}
}
collect_expr_table_refs(expr, refs);
}
Expr::Subquery(subquery) | Expr::Exists { subquery, .. } => {
collect_query_refs(subquery, refs);
if let Some(w) = &subquery.with {
for cte in &w.cte_tables {
collect_query_refs(&cte.query, refs);
}
}
}
Expr::BinaryOp { left, right, .. } => {
collect_expr_table_refs(left, refs);
collect_expr_table_refs(right, refs);
}
Expr::UnaryOp { expr: inner, .. }
| Expr::Nested(inner)
| Expr::IsNull(inner)
| Expr::IsNotNull(inner) => {
collect_expr_table_refs(inner, refs);
}
Expr::InList { expr, list, .. } => {
collect_expr_table_refs(expr, refs);
for item in list {
collect_expr_table_refs(item, refs);
}
}
Expr::Between {
expr, low, high, ..
} => {
collect_expr_table_refs(expr, refs);
collect_expr_table_refs(low, refs);
collect_expr_table_refs(high, refs);
}
Expr::Case {
operand,
conditions,
else_result,
..
} => {
if let Some(op) = operand {
collect_expr_table_refs(op, refs);
}
for case_when in conditions {
collect_expr_table_refs(&case_when.condition, refs);
collect_expr_table_refs(&case_when.result, refs);
}
if let Some(el) = else_result {
collect_expr_table_refs(el, refs);
}
}
Expr::Cast { expr: inner, .. } => {
collect_expr_table_refs(inner, refs);
}
Expr::Function(func) => {
if let FunctionArguments::List(arg_list) = &func.args {
for arg in &arg_list.args {
match arg {
FunctionArg::Unnamed(FunctionArgExpr::Expr(e))
| FunctionArg::Named {
arg: FunctionArgExpr::Expr(e),
..
} => collect_expr_table_refs(e, refs),
_ => {}
}
}
}
}
_ => {}
}
}
fn collect_relation_refs(relation: &TableFactor, refs: &mut HashSet<String>) {
match relation {
TableFactor::Table { name, .. } => {
if let Some(part) = name.0.last() {
let value = part
.as_ident()
.map(|ident| ident.value.clone())
.unwrap_or_else(|| part.to_string());
refs.insert(value.to_uppercase());
}
}
TableFactor::Derived { subquery, .. } => {
collect_query_refs(subquery, refs);
if let Some(w) = &subquery.with {
for cte in &w.cte_tables {
collect_query_refs(&cte.query, refs);
}
}
}
TableFactor::NestedJoin {
table_with_joins, ..
} => {
collect_relation_refs(&table_with_joins.relation, refs);
for join in &table_with_joins.joins {
collect_relation_refs(&join.relation, refs);
collect_join_constraint_refs(&join.join_operator, refs);
}
}
_ => {}
}
}
fn collect_order_by_refs(order_by: &OrderBy, refs: &mut HashSet<String>) {
if let OrderByKind::Expressions(order_exprs) = &order_by.kind {
for order_expr in order_exprs {
collect_expr_table_refs(&order_expr.expr, refs);
}
}
}
fn collect_join_constraint_refs(join_operator: &JoinOperator, refs: &mut HashSet<String>) {
let constraint = match join_operator {
JoinOperator::Join(c)
| JoinOperator::Inner(c)
| JoinOperator::LeftOuter(c)
| JoinOperator::RightOuter(c)
| JoinOperator::FullOuter(c)
| JoinOperator::LeftSemi(c)
| JoinOperator::RightSemi(c)
| JoinOperator::LeftAnti(c)
| JoinOperator::RightAnti(c) => c,
_ => return,
};
if let JoinConstraint::On(expr) = constraint {
collect_expr_table_refs(expr, refs);
}
}
fn find_cte_name_span(name: &Ident, ctx: &LintContext) -> Option<crate::types::Span> {
ident_span_in_statement(name, ctx)
}
fn ident_span_in_statement(name: &Ident, ctx: &LintContext) -> Option<crate::types::Span> {
use crate::analyzer::helpers::line_col_to_offset;
let start = line_col_to_offset(
ctx.sql,
name.span.start.line as usize,
name.span.start.column as usize,
)?;
let end = line_col_to_offset(
ctx.sql,
name.span.end.line as usize,
name.span.end.column as usize,
)?;
if start >= end {
return None;
}
if start < ctx.statement_range.start || end > ctx.statement_range.end {
return None;
}
Some(crate::types::Span::new(start, end))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::parse_sql;
fn check_sql(sql: &str) -> Vec<Issue> {
let stmts = parse_sql(sql).unwrap();
let rule = UnusedCte;
let ctx = LintContext {
sql,
statement_range: 0..sql.len(),
statement_index: 0,
};
let mut issues = Vec::new();
for stmt in &stmts {
issues.extend(rule.check(stmt, &ctx));
}
issues
}
#[test]
fn test_unused_cte_detected() {
let issues = check_sql("WITH unused AS (SELECT 1) SELECT 2");
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].code, "LINT_ST_003");
assert!(issues[0].message.contains("unused"));
}
#[test]
fn test_unused_cte_span_matches_cte_name() {
let sql = "WITH unused AS (SELECT 1) SELECT 2";
let issues = check_sql(sql);
let span = issues[0].span.expect("span");
assert_eq!(&sql[span.start..span.end], "unused");
}
#[test]
fn test_used_cte_ok() {
let issues = check_sql("WITH my_cte AS (SELECT 1) SELECT * FROM my_cte");
assert!(issues.is_empty());
}
#[test]
fn test_cte_referenced_by_later_cte() {
let issues = check_sql("WITH a AS (SELECT 1), b AS (SELECT * FROM a) SELECT * FROM b");
assert!(issues.is_empty());
}
#[test]
fn test_no_cte_ok() {
let issues = check_sql("SELECT * FROM t");
assert!(issues.is_empty());
}
#[test]
fn test_multiple_ctes_all_used() {
let issues = check_sql(
"WITH cte1 AS (SELECT a FROM t), cte2 AS (SELECT b FROM t) \
SELECT cte1.a, cte2.b FROM cte1 JOIN cte2 ON cte1.a = cte2.b",
);
assert!(issues.is_empty());
}
#[test]
fn test_multiple_ctes_one_unused() {
let issues = check_sql(
"WITH cte1 AS (SELECT a FROM t), cte2 AS (SELECT b FROM t), cte3 AS (SELECT c FROM t) \
SELECT * FROM cte1 JOIN cte3 ON cte1.a = cte3.c",
);
assert_eq!(issues.len(), 1);
assert!(issues[0].message.contains("cte2"));
}
#[test]
fn test_cte_used_in_subquery() {
let issues = check_sql(
"WITH cte AS (SELECT id FROM t) \
SELECT * FROM t2 WHERE id IN (SELECT id FROM cte)",
);
assert!(issues.is_empty());
}
#[test]
fn test_cte_used_in_exists_subquery() {
let issues = check_sql(
"WITH cte AS (SELECT id FROM t) \
SELECT 1 WHERE EXISTS (SELECT 1 FROM cte)",
);
assert!(issues.is_empty());
}
#[test]
fn test_cte_in_insert() {
let issues = check_sql("INSERT INTO target WITH unused AS (SELECT 1) SELECT 2");
assert_eq!(issues.len(), 1);
}
#[test]
fn test_with_insert_ctes_used_ok() {
let issues = check_sql(
"WITH a AS (SELECT 1), b AS (SELECT * FROM a) \
INSERT INTO target SELECT * FROM b",
);
assert!(
issues.is_empty(),
"expected no unused CTEs, got: {issues:#?}"
);
}
#[test]
fn test_cte_in_create_view() {
let issues = check_sql("CREATE VIEW v AS WITH unused AS (SELECT 1) SELECT 2");
assert_eq!(issues.len(), 1);
}
#[test]
fn test_chained_ctes_three_levels() {
let issues = check_sql(
"WITH a AS (SELECT 1), b AS (SELECT * FROM a), c AS (SELECT * FROM b) \
SELECT * FROM c",
);
assert!(issues.is_empty());
}
#[test]
fn test_cte_case_insensitive() {
let issues = check_sql("WITH My_Cte AS (SELECT 1) SELECT * FROM my_cte");
assert!(issues.is_empty());
}
#[test]
fn test_cte_used_in_join() {
let issues = check_sql(
"WITH cte AS (SELECT id FROM t) \
SELECT * FROM t2 JOIN cte ON t2.id = cte.id",
);
assert!(issues.is_empty());
}
#[test]
fn test_all_ctes_unused() {
let issues = check_sql("WITH a AS (SELECT 1), b AS (SELECT 2) SELECT 3");
assert_eq!(issues.len(), 2);
}
#[test]
fn test_update_cte_used_in_from() {
let sql = "\
WITH cte AS (SELECT id, name, description FROM table1) \
UPDATE table2 SET name = cte.name, description = cte.description \
FROM cte WHERE table2.id = cte.id";
assert!(check_sql(sql).is_empty());
}
#[test]
fn test_nested_cte_unused() {
let sql = "WITH a AS (WITH b AS (SELECT 1 FROM foo) SELECT 1) SELECT * FROM a";
let issues = check_sql(sql);
assert_eq!(issues.len(), 1);
assert!(issues[0].message.contains("b"));
}
#[test]
fn test_nested_with_cte_used() {
let sql = "\
WITH example_cte AS (SELECT 1), \
container_cte AS (\
WITH nested_cte AS (SELECT * FROM example_cte) \
SELECT * FROM nested_cte\
) SELECT * FROM container_cte";
assert!(check_sql(sql).is_empty());
}
#[test]
fn test_snowflake_delete_cte() {
let sql = "\
DELETE FROM MYTABLE1 \
USING (\
WITH MYCTE AS (SELECT COLUMN2 FROM MYTABLE3) \
SELECT COLUMN3 FROM MYTABLE3\
) X \
WHERE COLUMN1 = X.COLUMN3";
let issues = check_sql(sql);
assert_eq!(issues.len(), 1);
assert!(issues[0].message.to_uppercase().contains("MYCTE"));
}
}