use crate::linter::config::LintConfig;
use crate::linter::rule::{LintContext, LintRule};
use crate::types::{issue_codes, Dialect, Issue, IssueAutofixApplicability, IssuePatchEdit};
use sqlparser::ast::*;
use sqlparser::tokenizer::{Token, TokenWithSpan, Tokenizer, Whitespace};
use std::collections::{HashMap, HashSet};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum AliasCaseCheck {
Dialect,
CaseInsensitive,
QuotedCsNakedUpper,
QuotedCsNakedLower,
CaseSensitive,
}
impl AliasCaseCheck {
fn from_config(config: &LintConfig) -> Self {
match config
.rule_option_str(issue_codes::LINT_AL_005, "alias_case_check")
.unwrap_or("dialect")
.to_ascii_lowercase()
.as_str()
{
"case_insensitive" => Self::CaseInsensitive,
"quoted_cs_naked_upper" => Self::QuotedCsNakedUpper,
"quoted_cs_naked_lower" => Self::QuotedCsNakedLower,
"case_sensitive" => Self::CaseSensitive,
_ => Self::Dialect,
}
}
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
struct AliasRef {
name: String,
quoted: bool,
relation_key: Option<String>,
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
struct QualifierRef {
name: String,
quoted: bool,
}
pub struct UnusedTableAlias {
alias_case_check: AliasCaseCheck,
}
impl UnusedTableAlias {
pub fn from_config(config: &LintConfig) -> Self {
Self {
alias_case_check: AliasCaseCheck::from_config(config),
}
}
}
impl Default for UnusedTableAlias {
fn default() -> Self {
Self {
alias_case_check: AliasCaseCheck::Dialect,
}
}
}
impl LintRule for UnusedTableAlias {
fn code(&self) -> &'static str {
issue_codes::LINT_AL_005
}
fn name(&self) -> &'static str {
"Unused table alias"
}
fn description(&self) -> &'static str {
"Tables should not be aliased if that alias is not used."
}
fn check(&self, stmt: &Statement, ctx: &LintContext) -> Vec<Issue> {
let mut issues = Vec::new();
match stmt {
Statement::Query(q) => check_query(q, self.alias_case_check, ctx, &mut issues),
Statement::Insert(ins) => {
if let Some(ref source) = ins.source {
check_query(source, self.alias_case_check, ctx, &mut issues);
}
}
Statement::CreateView(CreateView { query, .. }) => {
check_query(query, self.alias_case_check, ctx, &mut issues)
}
Statement::CreateTable(create) => {
if let Some(ref q) = create.query {
check_query(q, self.alias_case_check, ctx, &mut issues);
}
}
Statement::Delete(delete) => {
check_delete(delete, self.alias_case_check, ctx, &mut issues);
}
_ => {}
}
if let Some(first_issue) = issues.first_mut() {
let autofix_edits: Vec<IssuePatchEdit> = al005_legacy_autofix_edits(
ctx.statement_sql(),
ctx.dialect(),
self.alias_case_check,
)
.into_iter()
.map(|(start, end)| IssuePatchEdit::new(ctx.span_from_statement_offset(start, end), ""))
.collect();
if !autofix_edits.is_empty() {
*first_issue = first_issue
.clone()
.with_autofix_edits(IssueAutofixApplicability::Safe, autofix_edits);
}
}
issues
}
}
fn check_query(
query: &Query,
alias_case_check: AliasCaseCheck,
ctx: &LintContext,
issues: &mut Vec<Issue>,
) {
if let Some(ref with) = query.with {
for cte in &with.cte_tables {
check_query(&cte.query, alias_case_check, ctx, issues);
}
}
match query.body.as_ref() {
SetExpr::Select(select) => check_select(
select,
query.order_by.as_ref(),
alias_case_check,
ctx,
issues,
),
_ => check_set_expr(&query.body, alias_case_check, ctx, issues),
}
}
fn check_set_expr(
body: &SetExpr,
alias_case_check: AliasCaseCheck,
ctx: &LintContext,
issues: &mut Vec<Issue>,
) {
match body {
SetExpr::Select(select) => {
check_select(select, None, alias_case_check, ctx, issues);
}
SetExpr::Query(q) => check_query(q, alias_case_check, ctx, issues),
SetExpr::SetOperation { left, right, .. } => {
check_set_expr(left, alias_case_check, ctx, issues);
check_set_expr(right, alias_case_check, ctx, issues);
}
_ => {}
}
}
fn check_select(
select: &Select,
order_by: Option<&OrderBy>,
alias_case_check: AliasCaseCheck,
ctx: &LintContext,
issues: &mut Vec<Issue>,
) {
for from_item in &select.from {
check_table_factor_subqueries(&from_item.relation, alias_case_check, ctx, issues);
for join in &from_item.joins {
check_table_factor_subqueries(&join.relation, alias_case_check, ctx, issues);
}
}
let mut aliases: HashMap<String, AliasRef> = HashMap::new();
for from_item in &select.from {
collect_aliases(&from_item.relation, ctx.dialect(), &mut aliases);
for join in &from_item.joins {
collect_aliases(&join.relation, ctx.dialect(), &mut aliases);
}
}
if aliases.is_empty() {
return;
}
let mut used_prefixes: HashSet<QualifierRef> = HashSet::new();
collect_identifier_prefixes_from_select(select, order_by, ctx.dialect(), &mut used_prefixes);
if matches!(ctx.dialect(), Dialect::Redshift) {
if let Some(qualify) = &select.qualify {
if include_qualify_alias_references(ctx.dialect(), select) {
for alias in aliases.values() {
if redshift_qualify_uses_alias_prefixed_identifier(qualify, &alias.name) {
used_prefixes.insert(QualifierRef {
name: alias.name.clone(),
quoted: alias.quoted,
});
}
}
}
}
}
emit_unused_alias_issues(
&aliases,
&used_prefixes,
alias_case_check,
ctx.dialect(),
ctx.statement_index,
issues,
);
}
fn check_delete(
delete: &Delete,
alias_case_check: AliasCaseCheck,
ctx: &LintContext,
issues: &mut Vec<Issue>,
) {
let mut aliases: HashMap<String, AliasRef> = HashMap::new();
let mut used_prefixes: HashSet<QualifierRef> = HashSet::new();
for table in delete_source_tables(delete) {
check_table_factor_subqueries(&table.relation, alias_case_check, ctx, issues);
collect_aliases(&table.relation, ctx.dialect(), &mut aliases);
collect_identifier_prefixes_from_table_factor(
&table.relation,
ctx.dialect(),
&mut used_prefixes,
);
for join in &table.joins {
check_table_factor_subqueries(&join.relation, alias_case_check, ctx, issues);
collect_aliases(&join.relation, ctx.dialect(), &mut aliases);
collect_identifier_prefixes_from_table_factor(
&join.relation,
ctx.dialect(),
&mut used_prefixes,
);
if let Some(constraint) = join_constraint(&join.join_operator) {
collect_identifier_prefixes(constraint, ctx.dialect(), &mut used_prefixes);
}
}
}
if let Some(selection) = &delete.selection {
collect_identifier_prefixes(selection, ctx.dialect(), &mut used_prefixes);
}
if let Some(returning) = &delete.returning {
for item in returning {
collect_identifier_prefixes_from_select_item(item, ctx.dialect(), &mut used_prefixes);
}
}
for order_expr in &delete.order_by {
collect_identifier_prefixes(&order_expr.expr, ctx.dialect(), &mut used_prefixes);
}
if let Some(limit) = &delete.limit {
collect_identifier_prefixes(limit, ctx.dialect(), &mut used_prefixes);
}
emit_unused_alias_issues(
&aliases,
&used_prefixes,
alias_case_check,
ctx.dialect(),
ctx.statement_index,
issues,
);
}
fn delete_source_tables(delete: &Delete) -> Vec<&TableWithJoins> {
let mut tables = Vec::new();
match &delete.from {
FromTable::WithFromKeyword(from) | FromTable::WithoutKeyword(from) => {
tables.extend(from.iter());
}
}
if let Some(using_tables) = &delete.using {
tables.extend(using_tables.iter());
}
tables
}
fn emit_unused_alias_issues(
aliases: &HashMap<String, AliasRef>,
used_prefixes: &HashSet<QualifierRef>,
alias_case_check: AliasCaseCheck,
dialect: Dialect,
statement_index: usize,
issues: &mut Vec<Issue>,
) {
if aliases.is_empty() {
return;
}
let mut used_alias_names: HashSet<String> = HashSet::new();
for alias in aliases.values() {
let used = used_prefixes
.iter()
.any(|prefix| qualifier_matches_alias(prefix, alias, alias_case_check, dialect));
if used {
used_alias_names.insert(alias.name.clone());
}
}
let mut relation_alias_counts: HashMap<String, usize> = HashMap::new();
let mut relations_with_used_alias: HashSet<String> = HashSet::new();
for alias in aliases.values() {
let Some(relation_key) = &alias.relation_key else {
continue;
};
*relation_alias_counts
.entry(relation_key.clone())
.or_insert(0) += 1;
if used_alias_names.contains(&alias.name) {
relations_with_used_alias.insert(relation_key.clone());
}
}
for alias in aliases.values() {
if used_alias_names.contains(&alias.name) {
continue;
}
let repeated_self_join_alias_exempt = alias.relation_key.as_ref().is_some_and(|key| {
relation_alias_counts.get(key).copied().unwrap_or_default() > 1
&& relations_with_used_alias.contains(key)
});
if repeated_self_join_alias_exempt {
continue;
}
issues.push(
Issue::warning(
issue_codes::LINT_AL_005,
format!(
"Table alias '{}' is defined but never referenced.",
alias.name
),
)
.with_statement(statement_index),
);
}
}
fn collect_identifier_prefixes_from_order_by(
order_by: &OrderBy,
dialect: Dialect,
prefixes: &mut HashSet<QualifierRef>,
) {
if let OrderByKind::Expressions(order_by_exprs) = &order_by.kind {
for order_expr in order_by_exprs {
collect_identifier_prefixes(&order_expr.expr, dialect, prefixes);
}
}
}
fn collect_identifier_prefixes_from_query(
query: &Query,
dialect: Dialect,
prefixes: &mut HashSet<QualifierRef>,
) {
if let Some(ref with) = query.with {
for cte in &with.cte_tables {
collect_identifier_prefixes_from_query(&cte.query, dialect, prefixes);
}
}
match query.body.as_ref() {
SetExpr::Select(select) => {
collect_identifier_prefixes_from_select(
select,
query.order_by.as_ref(),
dialect,
prefixes,
);
}
SetExpr::Query(q) => collect_identifier_prefixes_from_query(q, dialect, prefixes),
SetExpr::SetOperation { left, right, .. } => {
collect_identifier_prefixes_from_set_expr(left, dialect, prefixes);
collect_identifier_prefixes_from_set_expr(right, dialect, prefixes);
}
_ => {}
}
}
fn collect_identifier_prefixes_from_set_expr(
body: &SetExpr,
dialect: Dialect,
prefixes: &mut HashSet<QualifierRef>,
) {
match body {
SetExpr::Select(select) => {
collect_identifier_prefixes_from_select(select, None, dialect, prefixes)
}
SetExpr::Query(q) => collect_identifier_prefixes_from_query(q, dialect, prefixes),
SetExpr::SetOperation { left, right, .. } => {
collect_identifier_prefixes_from_set_expr(left, dialect, prefixes);
collect_identifier_prefixes_from_set_expr(right, dialect, prefixes);
}
_ => {}
}
}
fn collect_identifier_prefixes_from_select(
select: &Select,
order_by: Option<&OrderBy>,
dialect: Dialect,
prefixes: &mut HashSet<QualifierRef>,
) {
for item in &select.projection {
collect_identifier_prefixes_from_select_item(item, dialect, prefixes);
}
if let Some(ref prewhere) = select.prewhere {
collect_identifier_prefixes(prewhere, dialect, prefixes);
}
if let Some(ref selection) = select.selection {
collect_identifier_prefixes(selection, dialect, prefixes);
}
if let GroupByExpr::Expressions(exprs, _) = &select.group_by {
for expr in exprs {
collect_identifier_prefixes(expr, dialect, prefixes);
}
}
for expr in &select.cluster_by {
collect_identifier_prefixes(expr, dialect, prefixes);
}
for expr in &select.distribute_by {
collect_identifier_prefixes(expr, dialect, prefixes);
}
for sort_expr in &select.sort_by {
collect_identifier_prefixes(&sort_expr.expr, dialect, prefixes);
}
if let Some(ref having) = select.having {
collect_identifier_prefixes(having, dialect, prefixes);
}
if let Some(ref qualify) = select.qualify {
if include_qualify_alias_references(dialect, select) {
collect_identifier_prefixes(qualify, dialect, prefixes);
}
}
if let Some(Distinct::On(exprs)) = &select.distinct {
for expr in exprs {
collect_identifier_prefixes(expr, dialect, prefixes);
}
}
for named_window in &select.named_window {
if let NamedWindowExpr::WindowSpec(spec) = &named_window.1 {
for expr in &spec.partition_by {
collect_identifier_prefixes(expr, dialect, prefixes);
}
for order_expr in &spec.order_by {
collect_identifier_prefixes(&order_expr.expr, dialect, prefixes);
}
}
}
for lateral_view in &select.lateral_views {
collect_identifier_prefixes(&lateral_view.lateral_view, dialect, prefixes);
}
for connect_by_kind in &select.connect_by {
match connect_by_kind {
ConnectByKind::ConnectBy { relationships, .. } => {
for relationship in relationships {
collect_identifier_prefixes(relationship, dialect, prefixes);
}
}
ConnectByKind::StartWith { condition, .. } => {
collect_identifier_prefixes(condition, dialect, prefixes);
}
}
}
for from_item in &select.from {
collect_identifier_prefixes_from_table_factor(&from_item.relation, dialect, prefixes);
for join in &from_item.joins {
collect_identifier_prefixes_from_table_factor(&join.relation, dialect, prefixes);
if let Some(constraint) = join_constraint(&join.join_operator) {
collect_identifier_prefixes(constraint, dialect, prefixes);
}
}
}
if let Some(order_by) = order_by {
collect_identifier_prefixes_from_order_by(order_by, dialect, prefixes);
}
}
fn collect_aliases(
relation: &TableFactor,
dialect: Dialect,
aliases: &mut HashMap<String, AliasRef>,
) {
match relation {
TableFactor::Table {
name,
alias: Some(alias),
args,
..
} => {
if args.is_some() {
return;
}
if is_implicit_array_relation_alias(dialect, name, aliases) {
return;
}
let table_name = name.to_string();
let alias_name = alias.name.value.clone();
if alias_name.to_uppercase() != table_name.to_uppercase() {
aliases.insert(
alias_name.clone(),
AliasRef {
name: alias_name,
quoted: alias.name.quote_style.is_some(),
relation_key: Some(table_name.to_ascii_uppercase()),
},
);
}
}
TableFactor::Derived {
subquery,
alias: Some(alias),
..
} if derived_values_alias_can_be_unused(dialect, subquery) => {
aliases.insert(
alias.name.value.clone(),
AliasRef {
name: alias.name.value.clone(),
quoted: alias.name.quote_style.is_some(),
relation_key: None,
},
);
}
TableFactor::Function {
lateral: true,
alias: Some(alias),
..
} => {
aliases.insert(
alias.name.value.clone(),
AliasRef {
name: alias.name.value.clone(),
quoted: alias.name.quote_style.is_some(),
relation_key: None,
},
);
}
TableFactor::NestedJoin {
table_with_joins, ..
} => {
collect_aliases(&table_with_joins.relation, dialect, aliases);
for join in &table_with_joins.joins {
collect_aliases(&join.relation, dialect, aliases);
}
}
TableFactor::Pivot { table, .. }
| TableFactor::Unpivot { table, .. }
| TableFactor::MatchRecognize { table, .. } => collect_aliases(table, dialect, aliases),
_ => {}
}
}
fn collect_identifier_prefixes_from_select_item(
item: &SelectItem,
dialect: Dialect,
prefixes: &mut HashSet<QualifierRef>,
) {
match item {
SelectItem::UnnamedExpr(expr) | SelectItem::ExprWithAlias { expr, .. } => {
collect_identifier_prefixes(expr, dialect, prefixes);
}
SelectItem::QualifiedWildcard(name, _) => {
let name_str = name.to_string();
if let Some(prefix) = name_str.split('.').next() {
prefixes.insert(QualifierRef {
name: prefix
.trim_matches(|ch| matches!(ch, '"' | '`' | '\'' | '[' | ']'))
.to_string(),
quoted: prefix.starts_with('"')
|| prefix.starts_with('`')
|| prefix.starts_with('['),
});
}
}
_ => {}
}
}
fn collect_identifier_prefixes(
expr: &Expr,
dialect: Dialect,
prefixes: &mut HashSet<QualifierRef>,
) {
match expr {
Expr::CompoundIdentifier(parts) if parts.len() >= 2 => {
prefixes.insert(QualifierRef {
name: parts[0].value.clone(),
quoted: parts[0].quote_style.is_some(),
});
}
Expr::BinaryOp { left, right, .. } => {
collect_identifier_prefixes(left, dialect, prefixes);
collect_identifier_prefixes(right, dialect, prefixes);
}
Expr::UnaryOp { expr: inner, .. } => collect_identifier_prefixes(inner, dialect, prefixes),
Expr::Nested(inner) => collect_identifier_prefixes(inner, dialect, prefixes),
Expr::Function(func) => {
let function_name = function_name(func);
if let FunctionArguments::List(arg_list) = &func.args {
for (index, arg) in arg_list.args.iter().enumerate() {
match arg {
FunctionArg::Unnamed(FunctionArgExpr::Expr(e))
| FunctionArg::Named {
arg: FunctionArgExpr::Expr(e),
..
} => {
collect_identifier_prefixes(e, dialect, prefixes);
if function_arg_is_table_alias_reference(
dialect,
function_name.as_str(),
index,
) {
if let Expr::Identifier(ident) = e {
prefixes.insert(QualifierRef {
name: ident.value.clone(),
quoted: ident.quote_style.is_some(),
});
}
}
}
_ => {}
}
}
}
if let Some(filter) = &func.filter {
collect_identifier_prefixes(filter, dialect, prefixes);
}
for order_expr in &func.within_group {
collect_identifier_prefixes(&order_expr.expr, dialect, prefixes);
}
if let Some(WindowType::WindowSpec(spec)) = &func.over {
for expr in &spec.partition_by {
collect_identifier_prefixes(expr, dialect, prefixes);
}
for order_expr in &spec.order_by {
collect_identifier_prefixes(&order_expr.expr, dialect, prefixes);
}
}
}
Expr::IsNull(inner)
| Expr::IsNotNull(inner)
| Expr::Cast { expr: inner, .. }
| Expr::JsonAccess { value: inner, .. } => {
collect_identifier_prefixes(inner, dialect, prefixes);
}
Expr::Case {
operand,
conditions,
else_result,
..
} => {
if let Some(op) = operand {
collect_identifier_prefixes(op, dialect, prefixes);
}
for case_when in conditions {
collect_identifier_prefixes(&case_when.condition, dialect, prefixes);
collect_identifier_prefixes(&case_when.result, dialect, prefixes);
}
if let Some(el) = else_result {
collect_identifier_prefixes(el, dialect, prefixes);
}
}
Expr::InList { expr, list, .. } => {
collect_identifier_prefixes(expr, dialect, prefixes);
for item in list {
collect_identifier_prefixes(item, dialect, prefixes);
}
}
Expr::InSubquery { expr, subquery, .. } => {
collect_identifier_prefixes(expr, dialect, prefixes);
collect_identifier_prefixes_from_query(subquery, dialect, prefixes);
}
Expr::AnyOp { left, right, .. } | Expr::AllOp { left, right, .. } => {
collect_identifier_prefixes(left, dialect, prefixes);
collect_identifier_prefixes(right, dialect, prefixes);
}
Expr::Subquery(subquery) | Expr::Exists { subquery, .. } => {
collect_identifier_prefixes_from_query(subquery, dialect, prefixes);
}
Expr::Between {
expr, low, high, ..
} => {
collect_identifier_prefixes(expr, dialect, prefixes);
collect_identifier_prefixes(low, dialect, prefixes);
collect_identifier_prefixes(high, dialect, prefixes);
}
_ => {}
}
}
fn collect_identifier_prefixes_from_table_factor(
table_factor: &TableFactor,
dialect: Dialect,
prefixes: &mut HashSet<QualifierRef>,
) {
match table_factor {
TableFactor::Table { name, .. } => {
if let Some(prefix) = implicit_array_relation_prefix(dialect, name) {
prefixes.insert(prefix);
}
}
TableFactor::Derived {
lateral: true,
subquery,
..
} => collect_identifier_prefixes_from_query(subquery, dialect, prefixes),
TableFactor::TableFunction { expr, .. } => {
collect_identifier_prefixes(expr, dialect, prefixes);
}
TableFactor::Function { args, .. } => {
for arg in args {
collect_identifier_prefixes_from_function_arg(arg, dialect, prefixes);
}
}
TableFactor::UNNEST { array_exprs, .. } => {
for expr in array_exprs {
collect_identifier_prefixes(expr, dialect, prefixes);
}
}
TableFactor::JsonTable { json_expr, .. } | TableFactor::OpenJsonTable { json_expr, .. } => {
collect_identifier_prefixes(json_expr, dialect, prefixes);
}
TableFactor::NestedJoin {
table_with_joins, ..
} => {
collect_identifier_prefixes_from_table_factor(
&table_with_joins.relation,
dialect,
prefixes,
);
for join in &table_with_joins.joins {
collect_identifier_prefixes_from_table_factor(&join.relation, dialect, prefixes);
if let Some(constraint) = join_constraint(&join.join_operator) {
collect_identifier_prefixes(constraint, dialect, prefixes);
}
}
}
TableFactor::Pivot {
table,
aggregate_functions,
value_column,
default_on_null,
..
} => {
collect_identifier_prefixes_from_table_factor(table, dialect, prefixes);
for expr_with_alias in aggregate_functions {
collect_identifier_prefixes(&expr_with_alias.expr, dialect, prefixes);
}
for expr in value_column {
collect_identifier_prefixes(expr, dialect, prefixes);
}
if let Some(expr) = default_on_null {
collect_identifier_prefixes(expr, dialect, prefixes);
}
}
TableFactor::Unpivot {
table,
value,
columns,
..
} => {
collect_identifier_prefixes_from_table_factor(table, dialect, prefixes);
collect_identifier_prefixes(value, dialect, prefixes);
for expr_with_alias in columns {
collect_identifier_prefixes(&expr_with_alias.expr, dialect, prefixes);
}
}
TableFactor::MatchRecognize {
table,
partition_by,
order_by,
measures,
..
} => {
collect_identifier_prefixes_from_table_factor(table, dialect, prefixes);
for expr in partition_by {
collect_identifier_prefixes(expr, dialect, prefixes);
}
for order in order_by {
collect_identifier_prefixes(&order.expr, dialect, prefixes);
}
for measure in measures {
collect_identifier_prefixes(&measure.expr, dialect, prefixes);
}
}
TableFactor::XmlTable { row_expression, .. } => {
collect_identifier_prefixes(row_expression, dialect, prefixes);
}
_ => {}
}
}
fn collect_identifier_prefixes_from_function_arg(
arg: &FunctionArg,
dialect: Dialect,
prefixes: &mut HashSet<QualifierRef>,
) {
match arg {
FunctionArg::Unnamed(FunctionArgExpr::Expr(expr))
| FunctionArg::Named {
arg: FunctionArgExpr::Expr(expr),
..
} => collect_identifier_prefixes(expr, dialect, prefixes),
_ => {}
}
}
fn function_name(function: &Function) -> String {
function
.name
.0
.last()
.and_then(ObjectNamePart::as_ident)
.map(|ident| ident.value.to_ascii_uppercase())
.unwrap_or_default()
}
fn function_arg_is_table_alias_reference(
dialect: Dialect,
function_name: &str,
arg_index: usize,
) -> bool {
matches!(dialect, Dialect::Bigquery) && arg_index == 0 && function_name == "TO_JSON_STRING"
}
fn include_qualify_alias_references(dialect: Dialect, select: &Select) -> bool {
!matches!(dialect, Dialect::Redshift) || select.selection.is_none()
}
fn derived_values_alias_can_be_unused(dialect: Dialect, subquery: &Query) -> bool {
matches!(dialect, Dialect::Databricks) && matches!(subquery.body.as_ref(), SetExpr::Values(_))
}
fn redshift_qualify_uses_alias_prefixed_identifier(expr: &Expr, alias: &str) -> bool {
match expr {
Expr::Identifier(identifier) => {
let value = identifier.value.as_str();
value
.strip_prefix(alias)
.is_some_and(|suffix| suffix.starts_with('_'))
|| value
.to_ascii_uppercase()
.strip_prefix(&alias.to_ascii_uppercase())
.is_some_and(|suffix| suffix.starts_with('_'))
}
Expr::CompoundIdentifier(_) => false,
Expr::BinaryOp { left, right, .. }
| Expr::AnyOp { left, right, .. }
| Expr::AllOp { left, right, .. } => {
redshift_qualify_uses_alias_prefixed_identifier(left, alias)
|| redshift_qualify_uses_alias_prefixed_identifier(right, alias)
}
Expr::UnaryOp { expr: inner, .. }
| Expr::Nested(inner)
| Expr::IsNull(inner)
| Expr::IsNotNull(inner)
| Expr::Cast { expr: inner, .. } => {
redshift_qualify_uses_alias_prefixed_identifier(inner, alias)
}
Expr::InList { expr, list, .. } => {
redshift_qualify_uses_alias_prefixed_identifier(expr, alias)
|| list
.iter()
.any(|item| redshift_qualify_uses_alias_prefixed_identifier(item, alias))
}
Expr::Between {
expr, low, high, ..
} => {
redshift_qualify_uses_alias_prefixed_identifier(expr, alias)
|| redshift_qualify_uses_alias_prefixed_identifier(low, alias)
|| redshift_qualify_uses_alias_prefixed_identifier(high, alias)
}
Expr::Case {
operand,
conditions,
else_result,
..
} => {
operand
.as_ref()
.is_some_and(|inner| redshift_qualify_uses_alias_prefixed_identifier(inner, alias))
|| conditions.iter().any(|when| {
redshift_qualify_uses_alias_prefixed_identifier(&when.condition, alias)
|| redshift_qualify_uses_alias_prefixed_identifier(&when.result, alias)
})
|| else_result.as_ref().is_some_and(|inner| {
redshift_qualify_uses_alias_prefixed_identifier(inner, alias)
})
}
Expr::Function(function) => {
let args_match = if let FunctionArguments::List(arguments) = &function.args {
arguments.args.iter().any(|arg| match arg {
FunctionArg::Unnamed(FunctionArgExpr::Expr(expr))
| FunctionArg::Named {
arg: FunctionArgExpr::Expr(expr),
..
} => redshift_qualify_uses_alias_prefixed_identifier(expr, alias),
_ => false,
})
} else {
false
};
let filter_match = function.filter.as_ref().is_some_and(|filter| {
redshift_qualify_uses_alias_prefixed_identifier(filter, alias)
});
let within_group_match = function.within_group.iter().any(|order_expr| {
redshift_qualify_uses_alias_prefixed_identifier(&order_expr.expr, alias)
});
let over_match = match &function.over {
Some(WindowType::WindowSpec(spec)) => {
spec.partition_by
.iter()
.any(|expr| redshift_qualify_uses_alias_prefixed_identifier(expr, alias))
|| spec.order_by.iter().any(|order_expr| {
redshift_qualify_uses_alias_prefixed_identifier(&order_expr.expr, alias)
})
}
_ => false,
};
args_match || filter_match || within_group_match || over_match
}
_ => false,
}
}
fn implicit_array_relation_prefix(dialect: Dialect, name: &ObjectName) -> Option<QualifierRef> {
if !matches!(dialect, Dialect::Bigquery | Dialect::Redshift) {
return None;
}
if name.0.len() != 2 {
return None;
}
let first = name.0.first()?.as_ident()?;
Some(QualifierRef {
name: first.value.clone(),
quoted: first.quote_style.is_some(),
})
}
fn is_implicit_array_relation_alias(
dialect: Dialect,
name: &ObjectName,
aliases: &HashMap<String, AliasRef>,
) -> bool {
let Some(prefix) = implicit_array_relation_prefix(dialect, name) else {
return false;
};
aliases
.values()
.any(|alias| alias.name.eq_ignore_ascii_case(&prefix.name))
}
fn qualifier_matches_alias(
qualifier: &QualifierRef,
alias: &AliasRef,
alias_case_check: AliasCaseCheck,
dialect: Dialect,
) -> bool {
match alias_case_check {
AliasCaseCheck::CaseInsensitive => qualifier.name.eq_ignore_ascii_case(&alias.name),
AliasCaseCheck::CaseSensitive => qualifier.name == alias.name,
AliasCaseCheck::Dialect => {
normalize_identifier_for_dialect(&qualifier.name, qualifier.quoted, dialect)
== normalize_identifier_for_dialect(&alias.name, alias.quoted, dialect)
}
AliasCaseCheck::QuotedCsNakedUpper => {
normalize_case_for_mode(qualifier, alias_case_check)
== normalize_case_for_mode_alias(alias, alias_case_check)
}
AliasCaseCheck::QuotedCsNakedLower => {
normalize_case_for_mode(qualifier, alias_case_check)
== normalize_case_for_mode_alias(alias, alias_case_check)
}
}
}
fn normalize_identifier_for_dialect(identifier: &str, quoted: bool, dialect: Dialect) -> String {
if quoted && !quoted_identifiers_case_insensitive_for_dialect(dialect) {
identifier.to_string()
} else {
normalize_naked_identifier_for_dialect(identifier, dialect)
}
}
fn normalize_naked_identifier_for_dialect(identifier: &str, dialect: Dialect) -> String {
if matches!(
dialect,
Dialect::Postgres
| Dialect::Redshift
| Dialect::Mysql
| Dialect::Sqlite
| Dialect::Mssql
| Dialect::Clickhouse
) {
identifier.to_ascii_lowercase()
} else {
identifier.to_ascii_uppercase()
}
}
fn quoted_identifiers_case_insensitive_for_dialect(dialect: Dialect) -> bool {
matches!(
dialect,
Dialect::Duckdb | Dialect::Hive | Dialect::Sqlite | Dialect::Databricks
)
}
fn normalize_case_for_mode(reference: &QualifierRef, mode: AliasCaseCheck) -> String {
match mode {
AliasCaseCheck::QuotedCsNakedUpper => {
if reference.quoted {
reference.name.clone()
} else {
reference.name.to_ascii_uppercase()
}
}
AliasCaseCheck::QuotedCsNakedLower => {
if reference.quoted {
reference.name.clone()
} else {
reference.name.to_ascii_lowercase()
}
}
_ => reference.name.clone(),
}
}
fn normalize_case_for_mode_alias(alias: &AliasRef, mode: AliasCaseCheck) -> String {
match mode {
AliasCaseCheck::QuotedCsNakedUpper => {
if alias.quoted {
alias.name.clone()
} else {
alias.name.to_ascii_uppercase()
}
}
AliasCaseCheck::QuotedCsNakedLower => {
if alias.quoted {
alias.name.clone()
} else {
alias.name.to_ascii_lowercase()
}
}
_ => alias.name.clone(),
}
}
fn join_constraint(op: &JoinOperator) -> Option<&Expr> {
let constraint = match op {
JoinOperator::Join(c)
| JoinOperator::Left(c)
| JoinOperator::Inner(c)
| JoinOperator::Right(c)
| JoinOperator::LeftOuter(c)
| JoinOperator::RightOuter(c)
| JoinOperator::FullOuter(c)
| JoinOperator::LeftSemi(c)
| JoinOperator::RightSemi(c)
| JoinOperator::LeftAnti(c)
| JoinOperator::RightAnti(c) => c,
_ => return None,
};
match constraint {
JoinConstraint::On(expr) => Some(expr),
_ => None,
}
}
fn check_table_factor_subqueries(
relation: &TableFactor,
alias_case_check: AliasCaseCheck,
ctx: &LintContext,
issues: &mut Vec<Issue>,
) {
match relation {
TableFactor::Derived { subquery, .. } => {
check_query(subquery, alias_case_check, ctx, issues);
}
TableFactor::NestedJoin {
table_with_joins, ..
} => {
check_table_factor_subqueries(
&table_with_joins.relation,
alias_case_check,
ctx,
issues,
);
for join in &table_with_joins.joins {
check_table_factor_subqueries(&join.relation, alias_case_check, ctx, issues);
}
}
TableFactor::Pivot { table, .. }
| TableFactor::Unpivot { table, .. }
| TableFactor::MatchRecognize { table, .. } => {
check_table_factor_subqueries(table, alias_case_check, ctx, issues);
}
_ => {}
}
}
#[derive(Debug, Clone)]
struct LegacySimpleTableAliasDecl {
table_end: usize,
alias_end: usize,
alias: String,
quoted: bool,
}
#[derive(Clone)]
struct LegacyLocatedToken {
token: Token,
end: usize,
}
fn al005_legacy_autofix_edits(
sql: &str,
dialect: Dialect,
alias_case_check: AliasCaseCheck,
) -> Vec<(usize, usize)> {
let Some(decls) = legacy_collect_simple_table_alias_declarations(sql, dialect) else {
return Vec::new();
};
if decls.is_empty() {
return Vec::new();
}
let mut seen_aliases = HashSet::new();
let mut removals = Vec::new();
for decl in &decls {
let alias_key = decl.alias.to_ascii_lowercase();
if !seen_aliases.insert(alias_key.clone()) {
continue;
}
if legacy_is_sql_keyword(&decl.alias) || legacy_is_generated_alias_identifier(&decl.alias) {
continue;
}
if legacy_contains_alias_qualifier_dialect(
sql,
&decl.alias,
decl.quoted,
dialect,
alias_case_check,
) {
continue;
}
removals.extend(
decls
.iter()
.filter(|candidate| candidate.alias.eq_ignore_ascii_case(&alias_key))
.map(|candidate| (candidate.table_end, candidate.alias_end)),
);
}
removals.sort_unstable();
removals.dedup();
removals.retain(|(start, end)| start < end);
removals
}
fn legacy_collect_simple_table_alias_declarations(
sql: &str,
dialect: Dialect,
) -> Option<Vec<LegacySimpleTableAliasDecl>> {
let tokens = legacy_tokenize_with_offsets(sql, dialect)?;
let mut out = Vec::new();
let mut index = 0usize;
while index < tokens.len() {
if !legacy_token_matches_keyword(&tokens[index].token, "FROM")
&& !legacy_token_matches_keyword(&tokens[index].token, "JOIN")
{
index += 1;
continue;
}
let Some(next) = legacy_next_non_trivia_token(&tokens, index + 1) else {
index += 1;
continue;
};
index = legacy_try_parse_table_item(&tokens, next, dialect, &mut out);
while let Some(comma_index) = legacy_next_non_trivia_token(&tokens, index) {
if !matches!(tokens[comma_index].token, Token::Comma) {
break;
}
let Some(next_item) = legacy_next_non_trivia_token(&tokens, comma_index + 1) else {
index = comma_index + 1;
break;
};
index = legacy_try_parse_table_item(&tokens, next_item, dialect, &mut out);
}
}
Some(out)
}
fn legacy_try_parse_table_item(
tokens: &[LegacyLocatedToken],
start: usize,
dialect: Dialect,
out: &mut Vec<LegacySimpleTableAliasDecl>,
) -> usize {
if start >= tokens.len() {
return start;
}
if legacy_token_matches_keyword(&tokens[start].token, "LATERAL") {
if let Some(func_end) = legacy_skip_lateral_function_call(tokens, start + 1) {
let Some(mut alias_index) = legacy_next_non_trivia_token(tokens, func_end) else {
return func_end;
};
if legacy_token_matches_keyword(&tokens[alias_index].token, "AS") {
let Some(next_index) = legacy_next_non_trivia_token(tokens, alias_index + 1) else {
return alias_index + 1;
};
alias_index = next_index;
}
if let Some((alias_value, alias_quoted)) =
legacy_token_any_identifier(&tokens[alias_index].token)
{
out.push(LegacySimpleTableAliasDecl {
table_end: tokens[func_end - 1].end,
alias_end: tokens[alias_index].end,
alias: alias_value.to_string(),
quoted: alias_quoted,
});
return alias_index + 1;
}
return func_end;
}
return start + 1;
}
if matches!(tokens[start].token, Token::LParen)
&& matches!(dialect, Dialect::Databricks)
&& legacy_parenthesized_relation_starts_with_values(tokens, start)
{
let Some(paren_end) = legacy_skip_parenthesized(tokens, start) else {
return start + 1;
};
let table_end = tokens[paren_end - 1].end;
let Some(mut alias_index) = legacy_next_non_trivia_token(tokens, paren_end) else {
return paren_end;
};
if legacy_token_matches_keyword(&tokens[alias_index].token, "AS") {
let Some(next_index) = legacy_next_non_trivia_token(tokens, alias_index + 1) else {
return alias_index + 1;
};
alias_index = next_index;
}
let Some((alias_value, alias_quoted)) =
legacy_token_any_identifier(&tokens[alias_index].token)
else {
return paren_end;
};
let mut alias_end = tokens[alias_index].end;
let mut next_cursor = alias_index + 1;
if let Some(cols_start) = legacy_next_non_trivia_token(tokens, alias_index + 1) {
if matches!(tokens[cols_start].token, Token::LParen) {
if let Some(cols_end) = legacy_skip_parenthesized(tokens, cols_start) {
alias_end = tokens[cols_end - 1].end;
next_cursor = cols_end;
}
}
}
out.push(LegacySimpleTableAliasDecl {
table_end,
alias_end,
alias: alias_value.to_string(),
quoted: alias_quoted,
});
return next_cursor;
}
if legacy_token_any_identifier(&tokens[start].token).is_none() {
return start + 1;
}
let mut table_end = tokens[start].end;
let mut cursor = start + 1;
while let Some(dot_index) = legacy_next_non_trivia_token(tokens, cursor) {
if !matches!(tokens[dot_index].token, Token::Period) {
break;
}
let Some(next_index) = legacy_next_non_trivia_token(tokens, dot_index + 1) else {
break;
};
if legacy_token_any_identifier(&tokens[next_index].token).is_none() {
break;
}
table_end = tokens[next_index].end;
cursor = next_index + 1;
}
let Some(mut alias_index) = legacy_next_non_trivia_token(tokens, cursor) else {
return cursor;
};
if legacy_token_matches_keyword(&tokens[alias_index].token, "AS") {
let Some(next_index) = legacy_next_non_trivia_token(tokens, alias_index + 1) else {
return alias_index + 1;
};
alias_index = next_index;
}
let Some((alias_value, alias_quoted)) = legacy_token_any_identifier(&tokens[alias_index].token)
else {
return cursor;
};
out.push(LegacySimpleTableAliasDecl {
table_end,
alias_end: tokens[alias_index].end,
alias: alias_value.to_string(),
quoted: alias_quoted,
});
alias_index + 1
}
fn legacy_tokenize_with_offsets(sql: &str, dialect: Dialect) -> Option<Vec<LegacyLocatedToken>> {
let dialect = dialect.to_sqlparser_dialect();
let mut tokenizer = Tokenizer::new(dialect.as_ref(), sql);
let tokens = tokenizer.tokenize_with_location().ok()?;
let mut out = Vec::with_capacity(tokens.len());
for token in tokens {
let (_, end) = legacy_token_with_span_offsets(sql, &token)?;
out.push(LegacyLocatedToken {
token: token.token,
end,
});
}
Some(out)
}
fn legacy_token_with_span_offsets(sql: &str, token: &TokenWithSpan) -> Option<(usize, usize)> {
let start = legacy_line_col_to_offset(
sql,
token.span.start.line as usize,
token.span.start.column as usize,
)?;
let end = legacy_line_col_to_offset(
sql,
token.span.end.line as usize,
token.span.end.column as usize,
)?;
Some((start, end))
}
fn legacy_line_col_to_offset(sql: &str, line: usize, column: usize) -> Option<usize> {
if line == 0 || column == 0 {
return None;
}
let mut current_line = 1usize;
let mut current_col = 1usize;
for (offset, ch) in sql.char_indices() {
if current_line == line && current_col == column {
return Some(offset);
}
if ch == '\n' {
current_line += 1;
current_col = 1;
} else {
current_col += 1;
}
}
if current_line == line && current_col == column {
return Some(sql.len());
}
None
}
fn legacy_next_non_trivia_token(tokens: &[LegacyLocatedToken], mut start: usize) -> Option<usize> {
while start < tokens.len() {
if !legacy_is_trivia_token(&tokens[start].token) {
return Some(start);
}
start += 1;
}
None
}
fn legacy_skip_lateral_function_call(tokens: &[LegacyLocatedToken], start: usize) -> Option<usize> {
let func_index = legacy_next_non_trivia_token(tokens, start)?;
legacy_token_any_identifier(&tokens[func_index].token)?;
let lparen_index = legacy_next_non_trivia_token(tokens, func_index + 1)?;
if !matches!(tokens[lparen_index].token, Token::LParen) {
return None;
}
let mut depth = 1u32;
let mut cursor = lparen_index + 1;
while cursor < tokens.len() && depth > 0 {
match &tokens[cursor].token {
Token::LParen => depth += 1,
Token::RParen => depth -= 1,
_ => {}
}
cursor += 1;
}
if depth == 0 {
Some(cursor)
} else {
None
}
}
fn legacy_parenthesized_relation_starts_with_values(
tokens: &[LegacyLocatedToken],
lparen_index: usize,
) -> bool {
let Some(first_inner) = legacy_next_non_trivia_token(tokens, lparen_index + 1) else {
return false;
};
legacy_token_matches_keyword(&tokens[first_inner].token, "VALUES")
}
fn legacy_skip_parenthesized(tokens: &[LegacyLocatedToken], lparen_index: usize) -> Option<usize> {
if !matches!(tokens.get(lparen_index)?.token, Token::LParen) {
return None;
}
let mut depth = 1u32;
let mut cursor = lparen_index + 1;
while cursor < tokens.len() && depth > 0 {
match &tokens[cursor].token {
Token::LParen => depth += 1,
Token::RParen => depth -= 1,
_ => {}
}
cursor += 1;
}
if depth == 0 {
Some(cursor)
} else {
None
}
}
fn legacy_is_trivia_token(token: &Token) -> bool {
matches!(
token,
Token::Whitespace(
Whitespace::Space
| Whitespace::Newline
| Whitespace::Tab
| Whitespace::SingleLineComment { .. }
| Whitespace::MultiLineComment(_)
)
)
}
fn legacy_token_matches_keyword(token: &Token, keyword: &str) -> bool {
matches!(token, Token::Word(word) if word.value.eq_ignore_ascii_case(keyword))
}
fn legacy_token_any_identifier(token: &Token) -> Option<(&str, bool)> {
match token {
Token::Word(word) if legacy_is_simple_identifier(&word.value) => {
if word.quote_style.is_some() {
Some((&word.value, true))
} else {
Some((&word.value, false))
}
}
Token::SingleQuotedString(s) => Some((s.as_str(), true)),
_ => None,
}
}
fn legacy_contains_alias_qualifier_dialect(
sql: &str,
alias: &str,
alias_quoted: bool,
dialect: Dialect,
alias_case_check: AliasCaseCheck,
) -> bool {
if matches!(dialect, Dialect::Redshift)
&& legacy_redshift_qualify_uses_alias_prefixed_identifier(sql, alias)
{
return true;
}
let alias_bytes = alias.as_bytes();
if alias_bytes.is_empty() {
return false;
}
let normalized_alias = match alias_case_check {
AliasCaseCheck::Dialect => normalize_identifier_for_dialect(alias, alias_quoted, dialect),
AliasCaseCheck::CaseInsensitive => alias.to_ascii_lowercase(),
AliasCaseCheck::CaseSensitive => alias.to_string(),
AliasCaseCheck::QuotedCsNakedUpper => {
if alias_quoted {
alias.to_string()
} else {
alias.to_ascii_uppercase()
}
}
AliasCaseCheck::QuotedCsNakedLower => {
if alias_quoted {
alias.to_string()
} else {
alias.to_ascii_lowercase()
}
}
};
let bytes = sql.as_bytes();
let mut index = 0usize;
while index < bytes.len() {
let (ref_name, ref_quoted, ref_end) = if index < bytes.len()
&& (bytes[index] == b'"' || bytes[index] == b'`' || bytes[index] == b'[')
{
let close_char = match bytes[index] {
b'"' => b'"',
b'`' => b'`',
b'[' => b']',
_ => unreachable!(),
};
let start = index + 1;
let mut end = start;
while end < bytes.len() && bytes[end] != close_char {
end += 1;
}
if end >= bytes.len() {
index += 1;
continue;
}
let name = &sql[start..end];
(name.to_string(), true, end + 1)
} else if index < bytes.len() && legacy_is_ascii_ident_start(bytes[index]) {
let start = index;
let mut end = start;
while end < bytes.len() && legacy_is_ascii_ident_continue(bytes[end]) {
end += 1;
}
let name = &sql[start..end];
(name.to_string(), false, end)
} else {
index += 1;
continue;
};
if ref_end < bytes.len() && bytes[ref_end] == b'.' {
let normalized_ref = match alias_case_check {
AliasCaseCheck::Dialect => {
normalize_identifier_for_dialect(&ref_name, ref_quoted, dialect)
}
AliasCaseCheck::CaseInsensitive => ref_name.to_ascii_lowercase(),
AliasCaseCheck::CaseSensitive => ref_name.clone(),
AliasCaseCheck::QuotedCsNakedUpper => {
if ref_quoted {
ref_name.clone()
} else {
ref_name.to_ascii_uppercase()
}
}
AliasCaseCheck::QuotedCsNakedLower => {
if ref_quoted {
ref_name.clone()
} else {
ref_name.to_ascii_lowercase()
}
}
};
if normalized_ref == normalized_alias {
return true;
}
}
index = if ref_end > index { ref_end } else { index + 1 };
}
false
}
fn legacy_redshift_qualify_uses_alias_prefixed_identifier(sql: &str, alias: &str) -> bool {
let Some(tokens) = legacy_tokenize_with_offsets(sql, Dialect::Redshift) else {
return false;
};
let Some(qualify_index) = tokens
.iter()
.position(|token| legacy_token_matches_keyword(&token.token, "QUALIFY"))
else {
return false;
};
if tokens[..qualify_index]
.iter()
.any(|token| legacy_token_matches_keyword(&token.token, "WHERE"))
{
return false;
}
tokens[qualify_index + 1..]
.iter()
.filter_map(|token| legacy_token_reference_identifier(&token.token))
.any(|identifier| legacy_alias_prefixed_identifier(identifier, alias))
}
fn legacy_token_reference_identifier(token: &Token) -> Option<&str> {
match token {
Token::Word(word) => Some(word.value.as_str()),
_ => None,
}
}
fn legacy_alias_prefixed_identifier(identifier: &str, alias: &str) -> bool {
if identifier.is_empty() || alias.is_empty() {
return false;
}
identifier
.to_ascii_uppercase()
.strip_prefix(&alias.to_ascii_uppercase())
.is_some_and(|suffix| suffix.starts_with('_'))
}
fn legacy_is_generated_alias_identifier(alias: &str) -> bool {
let mut chars = alias.chars();
match chars.next() {
Some('t') => {}
_ => return false,
}
let mut saw_digit = false;
for ch in chars {
if !ch.is_ascii_digit() {
return false;
}
saw_digit = true;
}
saw_digit
}
fn legacy_is_sql_keyword(token: &str) -> bool {
matches!(
token.to_ascii_uppercase().as_str(),
"ALL"
| "ALTER"
| "AND"
| "ANY"
| "AS"
| "ASC"
| "BEGIN"
| "BETWEEN"
| "BOOLEAN"
| "BY"
| "CASE"
| "CAST"
| "CHECK"
| "COLUMN"
| "CONSTRAINT"
| "CREATE"
| "CROSS"
| "DEFAULT"
| "DELETE"
| "DESC"
| "DISTINCT"
| "DROP"
| "ELSE"
| "END"
| "EXCEPT"
| "EXISTS"
| "FALSE"
| "FETCH"
| "FOR"
| "FOREIGN"
| "FROM"
| "FULL"
| "GROUP"
| "HAVING"
| "IF"
| "IN"
| "INDEX"
| "INNER"
| "INSERT"
| "INT"
| "INTEGER"
| "INTERSECT"
| "INTO"
| "IS"
| "JOIN"
| "KEY"
| "LEFT"
| "LIKE"
| "LIMIT"
| "NOT"
| "NULL"
| "OFFSET"
| "ON"
| "OR"
| "ORDER"
| "OUTER"
| "OVER"
| "PARTITION"
| "PRIMARY"
| "REFERENCES"
| "RIGHT"
| "SELECT"
| "SET"
| "TABLE"
| "TEXT"
| "THEN"
| "TRUE"
| "UNION"
| "UNIQUE"
| "UPDATE"
| "USING"
| "VALUES"
| "VARCHAR"
| "VIEW"
| "WHEN"
| "WHERE"
| "WINDOW"
| "WITH"
)
}
fn legacy_is_simple_identifier(value: &str) -> bool {
let bytes = value.as_bytes();
if bytes.is_empty() || !legacy_is_ascii_ident_start(bytes[0]) {
return false;
}
bytes[1..]
.iter()
.copied()
.all(legacy_is_ascii_ident_continue)
}
fn legacy_is_ascii_ident_start(byte: u8) -> bool {
byte.is_ascii_alphabetic() || byte == b'_' || byte == b'#'
}
fn legacy_is_ascii_ident_continue(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || byte == b'_'
}
#[cfg(test)]
mod tests {
use super::*;
use crate::linter::config::LintConfig;
use crate::linter::rule::with_active_dialect;
use crate::parser::{parse_sql, parse_sql_with_dialect};
use crate::types::{Dialect, IssueAutofixApplicability};
fn check_sql(sql: &str) -> Vec<Issue> {
let stmts = parse_sql(sql).unwrap();
let rule = UnusedTableAlias::default();
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
}
fn apply_issue_autofix(sql: &str, issue: &Issue) -> Option<String> {
let autofix = issue.autofix.as_ref()?;
let mut edits = autofix.edits.clone();
edits.sort_by_key(|edit| (edit.span.start, edit.span.end));
let mut rewritten = sql.to_string();
for edit in edits.into_iter().rev() {
rewritten.replace_range(edit.span.start..edit.span.end, &edit.replacement);
}
Some(rewritten)
}
fn check_sql_in_dialect(sql: &str, dialect: Dialect) -> Vec<Issue> {
let stmts = parse_sql_with_dialect(sql, dialect).unwrap();
let rule = UnusedTableAlias::default();
let ctx = LintContext {
sql,
statement_range: 0..sql.len(),
statement_index: 0,
};
let mut issues = Vec::new();
with_active_dialect(dialect, || {
for stmt in &stmts {
issues.extend(rule.check(stmt, &ctx));
}
});
issues
}
#[test]
fn test_unused_alias_detected() {
let issues = check_sql("SELECT * FROM users u JOIN orders o ON users.id = orders.user_id");
assert_eq!(issues.len(), 2);
assert_eq!(issues[0].code, "LINT_AL_005");
}
#[test]
fn test_unused_alias_emits_safe_autofix_patch() {
let sql = "SELECT users.name FROM users AS u JOIN orders AS o ON users.id = orders.user_id";
let issues = check_sql(sql);
assert_eq!(issues.len(), 2);
let autofix = issues[0].autofix.as_ref().expect("autofix metadata");
assert_eq!(autofix.applicability, IssueAutofixApplicability::Safe);
let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
assert_eq!(
fixed,
"SELECT users.name FROM users JOIN orders ON users.id = orders.user_id"
);
}
#[test]
fn test_generated_alias_does_not_emit_autofix() {
let sql = "SELECT * FROM users AS t1";
let issues = check_sql(sql);
assert_eq!(issues.len(), 1);
assert!(
issues[0].autofix.is_none(),
"legacy AL005 parity skips generated aliases like t1"
);
}
#[test]
fn test_used_alias_ok() {
let issues = check_sql("SELECT u.name FROM users u JOIN orders o ON u.id = o.user_id");
assert!(issues.is_empty());
}
#[test]
fn test_single_table_unused_alias_detected() {
let issues = check_sql("SELECT * FROM users u");
assert_eq!(issues.len(), 1);
assert!(issues[0].message.contains("u"));
}
#[test]
fn test_single_table_alias_used_ok() {
let issues = check_sql("SELECT u.id FROM users u");
assert!(issues.is_empty());
}
#[test]
fn test_alias_used_in_where() {
let issues = check_sql(
"SELECT u.name FROM users u JOIN orders o ON u.id = o.user_id WHERE u.active = true",
);
assert!(issues.is_empty());
}
#[test]
fn test_alias_used_in_group_by() {
let issues = check_sql(
"SELECT u.name, COUNT(*) FROM users u JOIN orders o ON u.id = o.user_id GROUP BY u.name",
);
assert!(issues.is_empty());
}
#[test]
fn test_alias_used_in_having() {
let issues = check_sql(
"SELECT u.name, COUNT(o.id) FROM users u JOIN orders o ON u.id = o.user_id \
GROUP BY u.name HAVING COUNT(o.id) > 5",
);
assert!(issues.is_empty());
}
#[test]
fn test_alias_used_in_qualified_wildcard() {
let issues = check_sql("SELECT u.* FROM users u JOIN orders o ON u.id = o.user_id");
assert!(issues.is_empty());
}
#[test]
fn test_alias_unused_despite_qualified_wildcard() {
let issues = check_sql("SELECT u.* FROM users u JOIN orders o ON u.id = orders.user_id");
assert_eq!(issues.len(), 1);
assert!(issues[0].message.contains("o"));
}
#[test]
fn test_partial_alias_usage() {
let issues = check_sql("SELECT u.name FROM users u JOIN orders o ON u.id = orders.user_id");
assert_eq!(issues.len(), 1);
assert!(issues[0].message.contains("o"));
}
#[test]
fn test_three_tables_one_unused() {
let issues = check_sql(
"SELECT a.name, b.total \
FROM users a \
JOIN orders b ON a.id = b.user_id \
JOIN products c ON b.product_id = products.id",
);
assert_eq!(issues.len(), 1);
assert!(issues[0].message.contains("c"));
}
#[test]
fn test_no_aliases_ok() {
let issues =
check_sql("SELECT users.name FROM users JOIN orders ON users.id = orders.user_id");
assert!(issues.is_empty());
}
#[test]
fn test_self_join_with_aliases() {
let issues =
check_sql("SELECT a.name, b.name FROM users a JOIN users b ON a.manager_id = b.id");
assert!(issues.is_empty());
}
#[test]
fn test_alias_in_case_expression() {
let issues = check_sql(
"SELECT CASE WHEN u.active THEN 'yes' ELSE 'no' END \
FROM users u JOIN orders o ON u.id = o.user_id",
);
assert!(issues.is_empty());
}
#[test]
fn test_alias_used_in_order_by() {
let issues = check_sql(
"SELECT u.name \
FROM users u \
JOIN orders o ON users.id = orders.user_id \
ORDER BY o.created_at",
);
assert!(issues.is_empty());
}
#[test]
fn test_left_join_alias_used_in_on_clause() {
let issues = check_sql("SELECT u.name FROM users u LEFT JOIN orders o ON u.id = o.user_id");
assert!(issues.is_empty());
}
#[test]
fn test_alias_used_only_in_correlated_exists_subquery() {
let issues = check_sql(
"SELECT 1 \
FROM users u \
JOIN orders o ON 1 = 1 \
WHERE EXISTS (SELECT 1 WHERE u.id = o.user_id)",
);
assert!(issues.is_empty());
}
#[test]
fn test_alias_used_in_qualify_clause() {
let issues = check_sql(
"SELECT u.id \
FROM users u \
JOIN orders o ON users.id = orders.user_id \
QUALIFY ROW_NUMBER() OVER (PARTITION BY o.user_id ORDER BY o.user_id) = 1",
);
assert!(issues.is_empty());
}
#[test]
fn test_alias_used_in_named_window_clause() {
let issues = check_sql(
"SELECT SUM(u.id) OVER w \
FROM users u \
JOIN orders o ON users.id = orders.user_id \
WINDOW w AS (PARTITION BY o.user_id)",
);
assert!(issues.is_empty());
}
#[test]
fn test_unused_derived_alias_detected() {
let issues = check_sql(
"SELECT u.id \
FROM users u \
JOIN (SELECT id FROM orders) o2 ON u.id = u.id",
);
assert!(issues.is_empty());
}
#[test]
fn test_lateral_alias_is_ignored() {
let issues = check_sql("SELECT u.id FROM users u JOIN LATERAL (SELECT 1) lx ON TRUE");
assert!(issues.is_empty());
}
#[test]
fn test_alias_used_only_in_lateral_subquery_relation() {
let issues = check_sql(
"SELECT 1 \
FROM users u \
JOIN LATERAL (SELECT u.id) lx ON TRUE",
);
assert!(issues.is_empty());
}
#[test]
fn test_alias_used_only_in_unnest_join_relation() {
let issues = check_sql(
"SELECT 1 \
FROM users u \
LEFT JOIN UNNEST(u.tags) tag ON TRUE",
);
assert!(issues.is_empty());
}
#[test]
fn alias_case_check_case_sensitive_flags_case_mismatch() {
let config = LintConfig {
enabled: true,
disabled_rules: vec![],
rule_configs: std::collections::BTreeMap::from([(
"aliasing.unused".to_string(),
serde_json::json!({"alias_case_check": "case_sensitive"}),
)]),
};
let rule = UnusedTableAlias::from_config(&config);
let sql = "SELECT zoo.id, b.id FROM users AS \"Zoo\" JOIN books b ON zoo.id = b.user_id";
let stmts = parse_sql(sql).expect("parse");
let issues = rule.check(
&stmts[0],
&LintContext {
sql,
statement_range: 0..sql.len(),
statement_index: 0,
},
);
assert_eq!(issues.len(), 1);
assert!(issues[0].message.contains("Zoo"));
}
#[test]
fn alias_case_check_case_insensitive_allows_case_mismatch() {
let config = LintConfig {
enabled: true,
disabled_rules: vec![],
rule_configs: std::collections::BTreeMap::from([(
"LINT_AL_005".to_string(),
serde_json::json!({"alias_case_check": "case_insensitive"}),
)]),
};
let rule = UnusedTableAlias::from_config(&config);
let sql = "SELECT zoo.id, b.id FROM users AS \"Zoo\" JOIN books b ON zoo.id = b.user_id";
let stmts = parse_sql(sql).expect("parse");
let issues = rule.check(
&stmts[0],
&LintContext {
sql,
statement_range: 0..sql.len(),
statement_index: 0,
},
);
assert!(issues.is_empty());
}
#[test]
fn alias_case_check_quoted_cs_naked_upper_allows_unquoted_upper_fold_for_quoted_alias() {
let config = LintConfig {
enabled: true,
disabled_rules: vec![],
rule_configs: std::collections::BTreeMap::from([(
"aliasing.unused".to_string(),
serde_json::json!({"alias_case_check": "quoted_cs_naked_upper"}),
)]),
};
let rule = UnusedTableAlias::from_config(&config);
let sql = "SELECT foo.id, b.id FROM users AS \"FOO\" JOIN books b ON foo.id = b.user_id";
let stmts = parse_sql(sql).expect("parse");
let issues = rule.check(
&stmts[0],
&LintContext {
sql,
statement_range: 0..sql.len(),
statement_index: 0,
},
);
assert!(issues.is_empty());
}
#[test]
fn alias_case_check_quoted_cs_naked_lower_allows_unquoted_lower_fold_for_quoted_alias() {
let config = LintConfig {
enabled: true,
disabled_rules: vec![],
rule_configs: std::collections::BTreeMap::from([(
"aliasing.unused".to_string(),
serde_json::json!({"alias_case_check": "quoted_cs_naked_lower"}),
)]),
};
let rule = UnusedTableAlias::from_config(&config);
let sql = "SELECT FOO.id, b.id FROM users AS \"foo\" JOIN books b ON FOO.id = b.user_id";
let stmts = parse_sql(sql).expect("parse");
let issues = rule.check(
&stmts[0],
&LintContext {
sql,
statement_range: 0..sql.len(),
statement_index: 0,
},
);
assert!(issues.is_empty());
}
#[test]
fn dialect_mode_generic_allows_quoted_unquoted_fold_match() {
let issues = check_sql("SELECT a.col1 FROM tab1 AS \"A\"");
assert!(issues.is_empty());
}
#[test]
fn dialect_mode_generic_allows_quoted_prefix_against_unquoted_alias() {
let issues = check_sql("SELECT \"A\".col1 FROM tab1 AS a");
assert!(issues.is_empty());
}
#[test]
fn dialect_mode_generic_flags_single_quoted_alias_case_mismatch() {
let issues = check_sql("SELECT a.col1 FROM tab1 AS 'a'");
assert_eq!(issues.len(), 1);
assert!(issues[0].message.contains("a"));
}
#[test]
fn dialect_mode_postgres_allows_lower_fold_for_quoted_alias() {
let issues =
check_sql_in_dialect("SELECT A.col_1 FROM table_a AS \"a\"", Dialect::Postgres);
assert!(issues.is_empty());
}
#[test]
fn dialect_mode_snowflake_flags_mixed_quoted_case_mismatch() {
let issues =
check_sql_in_dialect("SELECT a.col_1 FROM table_a AS \"a\"", Dialect::Snowflake);
assert_eq!(issues.len(), 1);
assert!(issues[0].message.contains("a"));
}
#[test]
fn dialect_mode_bigquery_allows_backtick_quoted_alias_fold_match() {
let issues = check_sql_in_dialect("SELECT a.col1 FROM tab1 AS `A`", Dialect::Bigquery);
assert!(issues.is_empty());
}
#[test]
fn dialect_mode_redshift_allows_lower_fold_for_quoted_alias() {
let issues =
check_sql_in_dialect("SELECT A.col_1 FROM table_a AS \"a\"", Dialect::Redshift);
assert!(issues.is_empty());
}
#[test]
fn dialect_mode_redshift_flags_mixed_quoted_case_mismatch() {
let issues =
check_sql_in_dialect("SELECT a.col_1 FROM table_a AS \"A\"", Dialect::Redshift);
assert_eq!(issues.len(), 1);
assert!(issues[0].message.contains("A"));
}
#[test]
fn dialect_mode_mysql_allows_backtick_qualified_reference_against_unquoted_alias() {
let issues = check_sql_in_dialect(
"SELECT `nih`.`userID` FROM `flight_notification_item_history` AS nih",
Dialect::Mysql,
);
assert!(issues.is_empty());
}
#[test]
fn dialect_mode_duckdb_allows_case_insensitive_quoted_reference() {
let issues = check_sql_in_dialect("SELECT \"a\".col_1 FROM table_a AS A", Dialect::Duckdb);
assert!(issues.is_empty());
}
#[test]
fn dialect_mode_hive_allows_case_insensitive_quoted_reference() {
let issues = check_sql_in_dialect("SELECT `a`.col1 FROM tab1 AS A", Dialect::Hive);
assert!(issues.is_empty());
}
#[test]
fn flags_inner_subquery_unused_alias() {
let issues = check_sql("SELECT * FROM (SELECT * FROM my_tbl AS foo)");
assert_eq!(issues.len(), 1);
assert!(issues[0].message.contains("foo"));
}
#[test]
fn allows_unreferenced_subquery_alias() {
let issues = check_sql("SELECT * FROM (SELECT 1 AS a) subquery");
assert!(issues.is_empty());
}
#[test]
fn allows_postgres_generate_series_alias() {
let issues = check_sql_in_dialect(
"SELECT date_trunc('day', dd)::timestamp FROM generate_series('2022-02-01'::timestamp, NOW()::timestamp, '1 day'::interval) dd",
Dialect::Postgres,
);
assert!(issues.is_empty());
}
#[test]
fn flags_unused_snowflake_lateral_flatten_alias() {
let issues = check_sql_in_dialect(
"SELECT a.test1, a.test2, b.test3 \
FROM table1 AS a, \
LATERAL flatten(input => some_field) AS b, \
LATERAL flatten(input => b.value) AS c, \
LATERAL flatten(input => c.value) AS d, \
LATERAL flatten(input => d.value) AS e, \
LATERAL flatten(input => e.value) AS f",
Dialect::Snowflake,
);
assert_eq!(issues.len(), 1);
assert!(issues[0].message.contains("f"));
}
#[test]
fn flags_unused_alias_inside_snowflake_delete_using_cte() {
let issues = check_sql_in_dialect(
"DELETE FROM MYTABLE1 \
USING ( \
WITH MYCTE AS (SELECT COLUMN2 FROM MYTABLE3 AS MT3) \
SELECT COLUMN3 FROM MYTABLE3 \
) X \
WHERE COLUMN1 = X.COLUMN3",
Dialect::Snowflake,
);
assert_eq!(issues.len(), 1);
assert!(issues[0].message.contains("MT3"));
}
#[test]
fn allows_bigquery_to_json_string_table_alias_argument() {
let issues = check_sql_in_dialect(
"SELECT TO_JSON_STRING(t) FROM my_table AS t",
Dialect::Bigquery,
);
assert!(issues.is_empty());
}
#[test]
fn flags_ansi_to_json_string_table_alias_argument() {
let issues =
check_sql_in_dialect("SELECT TO_JSON_STRING(t) FROM my_table AS t", Dialect::Ansi);
assert_eq!(issues.len(), 1);
assert!(issues[0].message.contains("t"));
}
#[test]
fn redshift_qualify_after_from_counts_alias_usage() {
let issues = check_sql_in_dialect(
"SELECT * \
FROM store AS s \
INNER JOIN store_sales AS ss \
QUALIFY ROW_NUMBER() OVER (PARTITION BY ss.sold_date ORDER BY ss.sales_price DESC) <= 2",
Dialect::Redshift,
);
assert_eq!(issues.len(), 1);
assert!(issues[0].message.contains("s"));
}
#[test]
fn redshift_qualify_after_where_does_not_count_alias_usage() {
let issues = check_sql_in_dialect(
"SELECT * \
FROM store AS s \
INNER JOIN store_sales AS ss \
WHERE col = 1 \
QUALIFY ROW_NUMBER() OVER (PARTITION BY ss.sold_date ORDER BY ss.sales_price DESC) <= 2",
Dialect::Redshift,
);
assert_eq!(issues.len(), 2);
assert!(issues.iter().any(|issue| issue.message.contains("s")));
assert!(issues.iter().any(|issue| issue.message.contains("ss")));
}
#[test]
fn redshift_qualify_unqualified_alias_prefixed_identifier_counts_alias_usage() {
let issues = check_sql_in_dialect(
"SELECT * \
FROM #store_sales AS ss \
QUALIFY row_number() OVER (PARTITION BY ss_sold_date ORDER BY ss_sales_price DESC) <= 2",
Dialect::Redshift,
);
assert!(issues.is_empty());
}
#[test]
fn redshift_qualify_after_from_autofix_keeps_used_join_alias() {
let sql = "SELECT *\n\
FROM #store as s\n\
INNER JOIN #store_sales AS ss\n\
QUALIFY row_number() OVER (PARTITION BY ss_sold_date ORDER BY ss_sales_price DESC) <= 2";
let issues = check_sql_in_dialect(sql, Dialect::Redshift);
assert_eq!(issues.len(), 1);
let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
assert_eq!(
fixed,
"SELECT *\n\
FROM #store\n\
INNER JOIN #store_sales AS ss\n\
QUALIFY row_number() OVER (PARTITION BY ss_sold_date ORDER BY ss_sales_price DESC) <= 2"
);
}
#[test]
fn redshift_qualify_after_where_autofix_removes_both_unused_aliases() {
let sql = "SELECT *\n\
FROM #store as s\n\
INNER JOIN #store_sales AS ss\n\
WHERE col = 1\n\
QUALIFY row_number() OVER (PARTITION BY ss_sold_date ORDER BY ss_sales_price DESC) <= 2";
let issues = check_sql_in_dialect(sql, Dialect::Redshift);
assert_eq!(issues.len(), 2);
let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
assert_eq!(
fixed,
"SELECT *\n\
FROM #store\n\
INNER JOIN #store_sales\n\
WHERE col = 1\n\
QUALIFY row_number() OVER (PARTITION BY ss_sold_date ORDER BY ss_sales_price DESC) <= 2"
);
}
#[test]
fn sparksql_values_derived_alias_is_detected_and_autofixed() {
let sql = "SELECT *\n\
FROM (\n\
VALUES (1, 2), (3, 4)\n\
) AS t(c1, c2)";
let issues = check_sql_in_dialect(sql, Dialect::Databricks);
assert_eq!(issues.len(), 1);
let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
assert_eq!(
fixed,
"SELECT *\n\
FROM (\n\
VALUES (1, 2), (3, 4)\n\
)"
);
}
#[test]
fn allows_bigquery_implicit_array_table_reference() {
let issues = check_sql_in_dialect(
"WITH table_arr AS (SELECT [1,2,4,2] AS arr) \
SELECT arr \
FROM table_arr AS t, t.arr",
Dialect::Bigquery,
);
assert!(issues.is_empty());
}
#[test]
fn allows_redshift_super_array_relation_reference() {
let issues = check_sql_in_dialect(
"SELECT my_column, my_array_value \
FROM my_schema.my_table AS t, t.super_array AS my_array_value",
Dialect::Redshift,
);
assert!(issues.is_empty());
}
#[test]
fn allows_repeat_referenced_table_aliases() {
let issues = check_sql(
"SELECT ROW_NUMBER() OVER(PARTITION BY a.object_id ORDER BY a.object_id) \
FROM sys.objects a \
CROSS JOIN sys.objects b \
CROSS JOIN sys.objects c",
);
assert!(issues.is_empty());
}
#[test]
fn dialect_mode_databricks_allows_backtick_case_insensitive_reference() {
let issues =
check_sql_in_dialect("SELECT `a`.col_1 FROM table_a AS A", Dialect::Databricks);
assert!(issues.is_empty());
}
#[test]
fn snowflake_json_access_counts_as_alias_usage() {
let issues = check_sql_in_dialect(
"SELECT r.rec:foo::string FROM foo.bar AS r",
Dialect::Snowflake,
);
assert!(issues.is_empty());
}
#[test]
fn snowflake_lateral_flatten_unused_alias_detected_and_fixable() {
let sql = "SELECT r.rec:foo::string, value:bar::string \
FROM foo.bar AS r, LATERAL FLATTEN(input => rec:result) AS x";
let issues = check_sql_in_dialect(sql, Dialect::Snowflake);
assert_eq!(issues.len(), 1);
assert!(issues[0].message.contains("x"));
let autofix = issues[0].autofix.as_ref().expect("autofix metadata");
assert_eq!(autofix.applicability, IssueAutofixApplicability::Safe);
let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
assert!(
!fixed.contains("AS x"),
"autofix should remove the unused LATERAL alias"
);
}
#[test]
fn autofix_removes_double_quoted_alias_in_dialect_mode() {
let sql = "SELECT a.col_1\nFROM table_a AS \"A\"\n";
let issues = check_sql_in_dialect(sql, Dialect::Postgres);
assert_eq!(issues.len(), 1);
assert!(issues[0].message.contains("A"));
let autofix = issues[0].autofix.as_ref().expect("autofix metadata");
let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
assert_eq!(fixed, "SELECT a.col_1\nFROM table_a\n");
assert_eq!(autofix.applicability, IssueAutofixApplicability::Safe);
}
#[test]
fn autofix_removes_single_quoted_alias() {
let sql = "SELECT a.col1\nFROM tab1 as 'a'\n";
let issues = check_sql(sql);
assert_eq!(issues.len(), 1);
let autofix = issues[0].autofix.as_ref().expect("autofix metadata");
let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix");
assert_eq!(fixed, "SELECT a.col1\nFROM tab1\n");
assert_eq!(autofix.applicability, IssueAutofixApplicability::Safe);
}
}