1use crate::linter::config::LintConfig;
6use crate::linter::rule::{LintContext, LintRule};
7use crate::types::{issue_codes, Issue};
8use sqlparser::ast::{
9 Expr, Query, Select, SelectItem, SetExpr, Statement, TableFactor, TableWithJoins,
10};
11use std::collections::HashSet;
12
13#[derive(Clone, Copy, Debug, Eq, PartialEq)]
14enum AliasCaseCheck {
15 Dialect,
16 CaseInsensitive,
17 QuotedCsNakedUpper,
18 QuotedCsNakedLower,
19 CaseSensitive,
20}
21
22impl AliasCaseCheck {
23 fn from_config(config: &LintConfig) -> Self {
24 match config
25 .rule_option_str(issue_codes::LINT_AL_008, "alias_case_check")
26 .unwrap_or("dialect")
27 .to_ascii_lowercase()
28 .as_str()
29 {
30 "case_insensitive" => Self::CaseInsensitive,
31 "quoted_cs_naked_upper" => Self::QuotedCsNakedUpper,
32 "quoted_cs_naked_lower" => Self::QuotedCsNakedLower,
33 "case_sensitive" => Self::CaseSensitive,
34 _ => Self::Dialect,
35 }
36 }
37}
38
39#[derive(Clone, Debug, Eq, PartialEq)]
40struct ProjectionAlias {
41 name: String,
42 quoted: bool,
43}
44
45pub struct AliasingUniqueColumn {
46 alias_case_check: AliasCaseCheck,
47}
48
49impl AliasingUniqueColumn {
50 pub fn from_config(config: &LintConfig) -> Self {
51 Self {
52 alias_case_check: AliasCaseCheck::from_config(config),
53 }
54 }
55}
56
57impl Default for AliasingUniqueColumn {
58 fn default() -> Self {
59 Self {
60 alias_case_check: AliasCaseCheck::Dialect,
61 }
62 }
63}
64
65impl LintRule for AliasingUniqueColumn {
66 fn code(&self) -> &'static str {
67 issue_codes::LINT_AL_008
68 }
69
70 fn name(&self) -> &'static str {
71 "Unique column alias"
72 }
73
74 fn description(&self) -> &'static str {
75 "Column aliases should be unique within each clause."
76 }
77
78 fn check(&self, statement: &Statement, ctx: &LintContext) -> Vec<Issue> {
79 let duplicate = first_duplicate_column_alias_in_statement(statement, self.alias_case_check)
80 .or_else(|| {
81 fallback_duplicate_column_alias_in_sql(ctx.statement_sql(), self.alias_case_check)
82 });
83
84 if duplicate.is_none() {
85 return Vec::new();
86 }
87
88 vec![Issue::warning(
89 issue_codes::LINT_AL_008,
90 "Column aliases should be unique within SELECT projection.",
91 )
92 .with_statement(ctx.statement_index)]
93 }
94}
95
96fn first_duplicate_column_alias_in_statement(
97 statement: &Statement,
98 alias_case_check: AliasCaseCheck,
99) -> Option<String> {
100 match statement {
101 Statement::Query(query) => first_duplicate_column_alias_in_query(query, alias_case_check),
102 Statement::Insert(insert) => insert
103 .source
104 .as_deref()
105 .and_then(|query| first_duplicate_column_alias_in_query(query, alias_case_check)),
106 Statement::CreateView { query, .. } => {
107 first_duplicate_column_alias_in_query(query, alias_case_check)
108 }
109 Statement::CreateTable(create) => create
110 .query
111 .as_deref()
112 .and_then(|query| first_duplicate_column_alias_in_query(query, alias_case_check)),
113 _ => None,
114 }
115}
116
117fn first_duplicate_column_alias_in_query(
118 query: &Query,
119 alias_case_check: AliasCaseCheck,
120) -> Option<String> {
121 if let Some(with) = &query.with {
122 for cte in &with.cte_tables {
123 if let Some(duplicate) =
124 first_duplicate_column_alias_in_query(&cte.query, alias_case_check)
125 {
126 return Some(duplicate);
127 }
128 }
129 }
130
131 first_duplicate_column_alias_in_set_expr(&query.body, alias_case_check)
132}
133
134fn first_duplicate_column_alias_in_set_expr(
135 set_expr: &SetExpr,
136 alias_case_check: AliasCaseCheck,
137) -> Option<String> {
138 match set_expr {
139 SetExpr::Select(select) => first_duplicate_column_alias_in_select(select, alias_case_check),
140 SetExpr::Query(query) => first_duplicate_column_alias_in_query(query, alias_case_check),
141 SetExpr::SetOperation { left, right, .. } => {
142 first_duplicate_column_alias_in_set_expr(left, alias_case_check)
143 .or_else(|| first_duplicate_column_alias_in_set_expr(right, alias_case_check))
144 }
145 SetExpr::Insert(statement)
146 | SetExpr::Update(statement)
147 | SetExpr::Delete(statement)
148 | SetExpr::Merge(statement) => {
149 first_duplicate_column_alias_in_statement(statement, alias_case_check)
150 }
151 _ => None,
152 }
153}
154
155fn first_duplicate_column_alias_in_select(
156 select: &Select,
157 alias_case_check: AliasCaseCheck,
158) -> Option<String> {
159 let mut aliases = Vec::new();
160 for item in &select.projection {
161 if let Some(alias) = projected_column_alias(item) {
162 aliases.push(alias);
163 }
164 }
165
166 if let Some(duplicate) = first_duplicate_alias(&aliases, alias_case_check) {
167 return Some(duplicate);
168 }
169
170 for table_with_joins in &select.from {
171 if let Some(duplicate) = first_duplicate_column_alias_in_table_with_joins_children(
172 table_with_joins,
173 alias_case_check,
174 ) {
175 return Some(duplicate);
176 }
177 }
178
179 None
180}
181
182fn projected_column_alias(item: &SelectItem) -> Option<ProjectionAlias> {
183 match item {
184 SelectItem::ExprWithAlias { alias, .. } => Some(ProjectionAlias {
185 name: alias.value.clone(),
186 quoted: alias.quote_style.is_some(),
187 }),
188 SelectItem::UnnamedExpr(Expr::Identifier(identifier)) => Some(ProjectionAlias {
189 name: identifier.value.clone(),
190 quoted: identifier.quote_style.is_some(),
191 }),
192 SelectItem::UnnamedExpr(Expr::CompoundIdentifier(parts)) => {
193 parts.last().map(|part| ProjectionAlias {
194 name: part.value.clone(),
195 quoted: part.quote_style.is_some(),
196 })
197 }
198 _ => None,
199 }
200}
201
202fn first_duplicate_column_alias_in_table_with_joins_children(
203 table_with_joins: &TableWithJoins,
204 alias_case_check: AliasCaseCheck,
205) -> Option<String> {
206 first_duplicate_column_alias_in_table_factor_children(
207 &table_with_joins.relation,
208 alias_case_check,
209 )
210 .or_else(|| {
211 for join in &table_with_joins.joins {
212 if let Some(duplicate) = first_duplicate_column_alias_in_table_factor_children(
213 &join.relation,
214 alias_case_check,
215 ) {
216 return Some(duplicate);
217 }
218 }
219 None
220 })
221}
222
223fn first_duplicate_column_alias_in_table_factor_children(
224 table_factor: &TableFactor,
225 alias_case_check: AliasCaseCheck,
226) -> Option<String> {
227 match table_factor {
228 TableFactor::Derived { subquery, .. } => {
229 first_duplicate_column_alias_in_query(subquery, alias_case_check)
230 }
231 TableFactor::NestedJoin {
232 table_with_joins, ..
233 } => first_duplicate_column_alias_in_nested_scope(table_with_joins, alias_case_check),
234 TableFactor::Pivot { table, .. }
235 | TableFactor::Unpivot { table, .. }
236 | TableFactor::MatchRecognize { table, .. } => {
237 first_duplicate_column_alias_in_table_factor_children(table, alias_case_check)
238 }
239 _ => None,
240 }
241}
242
243fn first_duplicate_column_alias_in_nested_scope(
244 table_with_joins: &TableWithJoins,
245 alias_case_check: AliasCaseCheck,
246) -> Option<String> {
247 first_duplicate_column_alias_in_table_with_joins_children(table_with_joins, alias_case_check)
248}
249
250fn first_duplicate_alias(
251 values: &[ProjectionAlias],
252 alias_case_check: AliasCaseCheck,
253) -> Option<String> {
254 let mut seen: Vec<&ProjectionAlias> = Vec::new();
255 let mut seen_case_insensitive = HashSet::new();
256
257 for value in values {
258 if matches!(alias_case_check, AliasCaseCheck::CaseInsensitive) {
260 let key = value.name.to_ascii_uppercase();
261 if !seen_case_insensitive.insert(key) {
262 return Some(value.name.clone());
263 }
264 continue;
265 }
266
267 let is_duplicate = seen
268 .iter()
269 .any(|existing| aliases_match(existing, value, alias_case_check));
270 if is_duplicate {
271 return Some(value.name.clone());
272 }
273 seen.push(value);
274 }
275
276 None
277}
278
279fn fallback_duplicate_column_alias_in_sql(
280 sql: &str,
281 alias_case_check: AliasCaseCheck,
282) -> Option<String> {
283 let trimmed = sql.trim();
284 if trimmed.len() < 6 || !trimmed[..6].eq_ignore_ascii_case("select") {
285 return None;
286 }
287
288 if !trimmed.ends_with(',') {
291 return None;
292 }
293
294 let projection_sql = trimmed[6..].trim_start();
295 let aliases = fallback_projection_aliases(projection_sql);
296 if aliases.is_empty() {
297 return None;
298 }
299
300 first_duplicate_alias(&aliases, alias_case_check)
301}
302
303fn fallback_projection_aliases(projection_sql: &str) -> Vec<ProjectionAlias> {
304 projection_sql
305 .lines()
306 .filter_map(|line| {
307 let expr = line.trim().trim_end_matches(',').trim();
308 if expr.is_empty() {
309 return None;
310 }
311 projection_alias_from_fragment(expr)
312 })
313 .collect()
314}
315
316fn projection_alias_from_fragment(expr: &str) -> Option<ProjectionAlias> {
317 if expr.contains("{{") || expr.contains("{%") || expr.contains("{#") {
318 return None;
319 }
320
321 let parts = expr.split_whitespace().collect::<Vec<_>>();
322 if parts.is_empty() {
323 return None;
324 }
325
326 let alias_raw = if parts.len() >= 2 {
327 parts[parts.len() - 1]
328 } else {
329 parts[0]
330 };
331
332 let quoted =
333 alias_raw.starts_with('"') || alias_raw.starts_with('`') || alias_raw.starts_with('[');
334 let name = alias_raw
335 .trim_matches(|ch| matches!(ch, '"' | '`' | '[' | ']'))
336 .to_string();
337 if name.is_empty() {
338 return None;
339 }
340
341 Some(ProjectionAlias { name, quoted })
342}
343
344fn aliases_match(
345 left: &ProjectionAlias,
346 right: &ProjectionAlias,
347 alias_case_check: AliasCaseCheck,
348) -> bool {
349 match alias_case_check {
350 AliasCaseCheck::CaseInsensitive => left.name.eq_ignore_ascii_case(&right.name),
351 AliasCaseCheck::CaseSensitive => left.name == right.name,
352 AliasCaseCheck::Dialect => {
353 if left.quoted || right.quoted {
354 left.name == right.name
355 } else {
356 left.name.eq_ignore_ascii_case(&right.name)
357 }
358 }
359 AliasCaseCheck::QuotedCsNakedUpper | AliasCaseCheck::QuotedCsNakedLower => {
360 normalize_alias_for_mode(left, alias_case_check)
361 == normalize_alias_for_mode(right, alias_case_check)
362 }
363 }
364}
365
366fn normalize_alias_for_mode(alias: &ProjectionAlias, mode: AliasCaseCheck) -> String {
367 match mode {
368 AliasCaseCheck::QuotedCsNakedUpper => {
369 if alias.quoted {
370 alias.name.clone()
371 } else {
372 alias.name.to_ascii_uppercase()
373 }
374 }
375 AliasCaseCheck::QuotedCsNakedLower => {
376 if alias.quoted {
377 alias.name.clone()
378 } else {
379 alias.name.to_ascii_lowercase()
380 }
381 }
382 _ => alias.name.clone(),
383 }
384}
385
386#[cfg(test)]
387mod tests {
388 use super::*;
389 use crate::parser::parse_sql;
390
391 fn run(sql: &str) -> Vec<Issue> {
392 let statements = parse_sql(sql).expect("parse");
393 let rule = AliasingUniqueColumn::default();
394 statements
395 .iter()
396 .enumerate()
397 .flat_map(|(index, statement)| {
398 rule.check(
399 statement,
400 &LintContext {
401 sql,
402 statement_range: 0..sql.len(),
403 statement_index: index,
404 },
405 )
406 })
407 .collect()
408 }
409
410 #[test]
411 fn flags_duplicate_projection_alias() {
412 let issues = run("select a as x, b as x from t");
413 assert_eq!(issues.len(), 1);
414 assert_eq!(issues[0].code, issue_codes::LINT_AL_008);
415 }
416
417 #[test]
418 fn allows_unique_projection_aliases() {
419 let issues = run("select a as x, b as y from t");
420 assert!(issues.is_empty());
421 }
422
423 #[test]
424 fn allows_same_alias_in_different_cte_scopes() {
425 let sql = "with a as (select col as x from t1), b as (select col as x from t2) select * from a join b on a.x = b.x";
426 let issues = run(sql);
427 assert!(issues.is_empty());
428 }
429
430 #[test]
431 fn flags_duplicate_alias_in_nested_subquery() {
432 let sql = "select * from (select a as x, b as x from t) s";
433 let issues = run(sql);
434 assert_eq!(issues.len(), 1);
435 }
436
437 #[test]
438 fn flags_duplicate_unaliased_column_reference() {
439 let issues = run("select foo, foo from t");
440 assert_eq!(issues.len(), 1);
441 assert_eq!(issues[0].code, issue_codes::LINT_AL_008);
442 }
443
444 #[test]
445 fn flags_alias_collision_with_unaliased_reference() {
446 let issues = run("select foo, a as foo from t");
447 assert_eq!(issues.len(), 1);
448 assert_eq!(issues[0].code, issue_codes::LINT_AL_008);
449 }
450
451 #[test]
452 fn default_dialect_mode_does_not_flag_quoted_case_mismatch() {
453 let issues = run("select \"A\", a from t");
454 assert!(issues.is_empty());
455 }
456
457 #[test]
458 fn alias_case_check_case_sensitive_allows_case_mismatch() {
459 let sql = "select a, A from t";
460 let statements = parse_sql(sql).expect("parse");
461 let rule = AliasingUniqueColumn::from_config(&LintConfig {
462 enabled: true,
463 disabled_rules: vec![],
464 rule_configs: std::collections::BTreeMap::from([(
465 "aliasing.unique.column".to_string(),
466 serde_json::json!({"alias_case_check": "case_sensitive"}),
467 )]),
468 });
469 let issues = rule.check(
470 &statements[0],
471 &LintContext {
472 sql,
473 statement_range: 0..sql.len(),
474 statement_index: 0,
475 },
476 );
477 assert!(issues.is_empty());
478 }
479
480 #[test]
481 fn alias_case_check_case_sensitive_flags_exact_duplicates() {
482 let sql = "select a, a from t";
483 let statements = parse_sql(sql).expect("parse");
484 let rule = AliasingUniqueColumn::from_config(&LintConfig {
485 enabled: true,
486 disabled_rules: vec![],
487 rule_configs: std::collections::BTreeMap::from([(
488 "LINT_AL_008".to_string(),
489 serde_json::json!({"alias_case_check": "case_sensitive"}),
490 )]),
491 });
492 let issues = rule.check(
493 &statements[0],
494 &LintContext {
495 sql,
496 statement_range: 0..sql.len(),
497 statement_index: 0,
498 },
499 );
500 assert_eq!(issues.len(), 1);
501 }
502
503 #[test]
504 fn alias_case_check_quoted_cs_naked_upper_flags_upper_fold_match() {
505 let sql = "select \"FOO\", foo from t";
506 let statements = parse_sql(sql).expect("parse");
507 let rule = AliasingUniqueColumn::from_config(&LintConfig {
508 enabled: true,
509 disabled_rules: vec![],
510 rule_configs: std::collections::BTreeMap::from([(
511 "aliasing.unique.column".to_string(),
512 serde_json::json!({"alias_case_check": "quoted_cs_naked_upper"}),
513 )]),
514 });
515 let issues = rule.check(
516 &statements[0],
517 &LintContext {
518 sql,
519 statement_range: 0..sql.len(),
520 statement_index: 0,
521 },
522 );
523 assert_eq!(issues.len(), 1);
524 }
525
526 #[test]
527 fn alias_case_check_quoted_cs_naked_upper_allows_nonmatching_quoted_case() {
528 let sql = "select \"foo\", foo from t";
529 let statements = parse_sql(sql).expect("parse");
530 let rule = AliasingUniqueColumn::from_config(&LintConfig {
531 enabled: true,
532 disabled_rules: vec![],
533 rule_configs: std::collections::BTreeMap::from([(
534 "aliasing.unique.column".to_string(),
535 serde_json::json!({"alias_case_check": "quoted_cs_naked_upper"}),
536 )]),
537 });
538 let issues = rule.check(
539 &statements[0],
540 &LintContext {
541 sql,
542 statement_range: 0..sql.len(),
543 statement_index: 0,
544 },
545 );
546 assert!(issues.is_empty());
547 }
548
549 #[test]
550 fn alias_case_check_quoted_cs_naked_lower_flags_lower_fold_match() {
551 let sql = "select \"foo\", FOO from t";
552 let statements = parse_sql(sql).expect("parse");
553 let rule = AliasingUniqueColumn::from_config(&LintConfig {
554 enabled: true,
555 disabled_rules: vec![],
556 rule_configs: std::collections::BTreeMap::from([(
557 "aliasing.unique.column".to_string(),
558 serde_json::json!({"alias_case_check": "quoted_cs_naked_lower"}),
559 )]),
560 });
561 let issues = rule.check(
562 &statements[0],
563 &LintContext {
564 sql,
565 statement_range: 0..sql.len(),
566 statement_index: 0,
567 },
568 );
569 assert_eq!(issues.len(), 1);
570 }
571
572 #[test]
573 fn flags_multiple_reused_aliases_in_projection() {
574 let sql = "select\n foo,\n b as foo,\n c as bar,\n bar,\n d foo\nfrom t";
577 let issues = run(sql);
578 assert_eq!(issues.len(), 1);
579 assert_eq!(issues[0].code, issue_codes::LINT_AL_008);
580 }
581
582 #[test]
583 fn statementless_trailing_comma_fragment_detects_duplicate_aliases() {
584 let sql = "select\n foo,\n b as foo,\n c as bar,\n bar,\n d foo,\n";
585 let synthetic = parse_sql("SELECT 1").expect("parse");
586 let rule = AliasingUniqueColumn::default();
587 let issues = rule.check(
588 &synthetic[0],
589 &LintContext {
590 sql,
591 statement_range: 0..sql.len(),
592 statement_index: 0,
593 },
594 );
595 assert_eq!(issues.len(), 1);
596 assert_eq!(issues[0].code, issue_codes::LINT_AL_008);
597 }
598
599 #[test]
600 fn alias_case_check_quoted_cs_naked_lower_allows_nonmatching_quoted_case() {
601 let sql = "select \"FOO\", FOO from t";
602 let statements = parse_sql(sql).expect("parse");
603 let rule = AliasingUniqueColumn::from_config(&LintConfig {
604 enabled: true,
605 disabled_rules: vec![],
606 rule_configs: std::collections::BTreeMap::from([(
607 "aliasing.unique.column".to_string(),
608 serde_json::json!({"alias_case_check": "quoted_cs_naked_lower"}),
609 )]),
610 });
611 let issues = rule.check(
612 &statements[0],
613 &LintContext {
614 sql,
615 statement_range: 0..sql.len(),
616 statement_index: 0,
617 },
618 );
619 assert!(issues.is_empty());
620 }
621}