1use crate::linter::rule::{LintContext, LintRule};
17use crate::linter::visit;
18use crate::types::{issue_codes, Issue, IssueAutofixApplicability, IssuePatchEdit, Span};
19use regex::Regex;
20use sqlparser::ast::{Expr, Spanned, Statement, Value};
21use sqlparser::tokenizer::{Token, TokenWithSpan, Tokenizer, Whitespace};
22use std::sync::OnceLock;
23
24pub struct StructureSimpleCase;
25
26impl LintRule for StructureSimpleCase {
27 fn code(&self) -> &'static str {
28 issue_codes::LINT_ST_002
29 }
30
31 fn name(&self) -> &'static str {
32 "Structure simple case"
33 }
34
35 fn description(&self) -> &'static str {
36 "Unnecessary 'CASE' statement."
37 }
38
39 fn check(&self, stmt: &Statement, ctx: &LintContext) -> Vec<Issue> {
40 let mut issues = Vec::new();
41
42 visit::visit_expressions(stmt, &mut |expr| {
43 let Some(rewrite) = classify_unnecessary_case(expr) else {
44 return;
45 };
46
47 let mut issue = Issue::info(
48 issue_codes::LINT_ST_002,
49 "Unnecessary CASE statement. Use COALESCE function or simple column reference.",
50 )
51 .with_statement(ctx.statement_index);
52
53 if let Some((span, applicability, edits)) = build_autofix(ctx, expr, &rewrite) {
54 issue = issue
55 .with_span(span)
56 .with_autofix_edits(applicability, edits);
57 }
58
59 issues.push(issue);
60 });
61
62 if issues.is_empty() && statementless_template_case_requires_st02(ctx.statement_sql()) {
63 issues.push(
64 Issue::info(
65 issue_codes::LINT_ST_002,
66 "Unnecessary CASE statement. Use COALESCE function or simple column reference.",
67 )
68 .with_statement(ctx.statement_index),
69 );
70 }
71
72 issues
73 }
74}
75
76#[derive(Debug, Clone)]
82enum UnnecessaryCaseKind {
83 BoolCoalesce,
85 BoolCoalesceNegated,
87 NullCoalesce,
89 ColumnIdentity,
91}
92
93fn classify_unnecessary_case(expr: &Expr) -> Option<UnnecessaryCaseKind> {
95 let Expr::Case {
96 operand: None,
97 conditions,
98 else_result,
99 ..
100 } = expr
101 else {
102 return None;
103 };
104
105 if conditions.len() != 1 {
107 return None;
108 }
109
110 let when = &conditions[0];
111 let condition = &when.condition;
112 let result = &when.result;
113
114 if let Some(result_bool) = expr_bool_value(result) {
118 if let Some(else_bool) = else_result.as_deref().and_then(expr_bool_value) {
119 if result_bool && !else_bool {
121 return Some(UnnecessaryCaseKind::BoolCoalesce);
123 } else if !result_bool && else_bool {
124 return Some(UnnecessaryCaseKind::BoolCoalesceNegated);
126 }
127 }
128 }
129
130 if let Expr::IsNull(checked_expr) = condition {
135 return classify_null_check_case(checked_expr, result, else_result.as_deref(), true);
136 }
137
138 if let Expr::IsNotNull(checked_expr) = condition {
140 return classify_null_check_case(checked_expr, result, else_result.as_deref(), false);
141 }
142
143 None
144}
145
146fn classify_null_check_case(
148 checked_expr: &Expr,
149 then_result: &Expr,
150 else_result: Option<&Expr>,
151 is_null_check: bool,
152) -> Option<UnnecessaryCaseKind> {
153 let checked_text = format!("{checked_expr}");
155 let then_text = format!("{then_result}");
156 let else_text = else_result.map(|e| format!("{e}"));
157
158 if is_null_check {
159 if let Some(ref else_t) = else_text {
161 if else_t == &checked_text {
162 if is_null_expr(then_result) {
163 return Some(UnnecessaryCaseKind::ColumnIdentity);
165 }
166 return Some(UnnecessaryCaseKind::NullCoalesce);
168 }
169 }
170 } else {
171 if then_text == checked_text {
173 match &else_text {
174 Some(et) if is_null_text(et) => {
175 return Some(UnnecessaryCaseKind::ColumnIdentity);
177 }
178 None => {
179 return Some(UnnecessaryCaseKind::ColumnIdentity);
181 }
182 Some(_) => {
183 return Some(UnnecessaryCaseKind::NullCoalesce);
185 }
186 }
187 }
188 }
189
190 None
191}
192
193fn expr_bool_value(expr: &Expr) -> Option<bool> {
194 match expr {
195 Expr::Value(v) => match &v.value {
196 Value::Boolean(b) => Some(*b),
197 _ => None,
198 },
199 _ => None,
200 }
201}
202
203fn is_null_expr(expr: &Expr) -> bool {
204 matches!(expr, Expr::Value(v) if matches!(v.value, Value::Null))
205}
206
207fn is_null_text(s: &str) -> bool {
208 s.eq_ignore_ascii_case("NULL")
209}
210
211fn statementless_template_case_requires_st02(sql: &str) -> bool {
212 if !contains_template_tags(sql) {
213 return false;
214 }
215
216 static RE: OnceLock<Regex> = OnceLock::new();
217 let pattern = RE.get_or_init(|| {
218 Regex::new(
219 r"(?is)\bcase\b.*?\bwhen\b\s+([a-zA-Z_][\w\.]*)\s+is\s+null\s+then\s+(\{\{.*?\}\})\s+else\s+([a-zA-Z_][\w\.]*)\s+end\b",
220 )
221 .expect("valid ST02 template fallback regex")
222 });
223
224 pattern.captures(sql).is_some_and(|caps| {
225 let checked = caps.get(1).map_or("", |m| m.as_str());
226 let else_expr = caps.get(3).map_or("", |m| m.as_str());
227 !checked.is_empty() && checked.eq_ignore_ascii_case(else_expr)
228 })
229}
230
231fn contains_template_tags(sql: &str) -> bool {
232 sql.contains("{{") || sql.contains("{%") || sql.contains("{#")
233}
234
235fn build_autofix(
240 ctx: &LintContext,
241 expr: &Expr,
242 rewrite: &UnnecessaryCaseKind,
243) -> Option<(Span, IssueAutofixApplicability, Vec<IssuePatchEdit>)> {
244 let (expr_start, expr_end) = expr_statement_offsets(ctx, expr)?;
245 let expr_span = ctx.span_from_statement_offset(expr_start, expr_end);
246
247 let applicability = if span_contains_comment(ctx, expr_span) {
248 IssueAutofixApplicability::Unsafe
249 } else {
250 IssueAutofixApplicability::Safe
251 };
252
253 let Expr::Case {
254 conditions,
255 else_result,
256 ..
257 } = expr
258 else {
259 return None;
260 };
261 let when = conditions.first()?;
262 let condition = &when.condition;
263
264 let replacement = match rewrite {
265 UnnecessaryCaseKind::BoolCoalesce => {
266 let cond_text = source_text_for_expr(ctx, condition)?;
267 format!("coalesce({cond_text}, false)")
268 }
269 UnnecessaryCaseKind::BoolCoalesceNegated => {
270 let cond_text = source_text_for_expr(ctx, condition)?;
271 format!("not coalesce({cond_text}, false)")
272 }
273 UnnecessaryCaseKind::NullCoalesce => {
274 let (checked_expr, fallback_expr) =
275 null_coalesce_operands(condition, &when.result, else_result.as_deref())?;
276 let checked_text = source_text_for_expr(ctx, checked_expr)?;
277 let fallback_text = source_text_for_expr(ctx, fallback_expr)?;
278 format!("coalesce({checked_text}, {fallback_text})")
279 }
280 UnnecessaryCaseKind::ColumnIdentity => {
281 let col_expr = column_identity_expr(condition, &when.result, else_result.as_deref())?;
282 source_text_for_expr(ctx, col_expr)?
283 }
284 };
285
286 Some((
287 expr_span,
288 applicability,
289 vec![IssuePatchEdit::new(expr_span, replacement)],
290 ))
291}
292
293fn source_text_for_expr(ctx: &LintContext, expr: &Expr) -> Option<String> {
299 let display_text = format!("{expr}");
300
301 let Some((start, end)) = expr_statement_offsets(ctx, expr) else {
302 return if display_text.is_empty() {
303 None
304 } else {
305 Some(normalize_keywords_to_match_source(
306 ctx.statement_sql(),
307 &display_text,
308 ))
309 };
310 };
311 let sql = ctx.statement_sql();
312 if end > sql.len() || start > end {
313 return if display_text.is_empty() {
314 None
315 } else {
316 Some(normalize_keywords_to_match_source(sql, &display_text))
317 };
318 }
319
320 let source = &sql[start..end];
321
322 if source.len() >= display_text.len() {
326 return Some(source.to_string());
327 }
328
329 Some(normalize_keywords_to_match_source(sql, &display_text))
333}
334
335fn normalize_keywords_to_match_source(context_sql: &str, text: &str) -> String {
337 let source_uses_lower = context_sql.contains(" and ")
340 || context_sql.contains(" or ")
341 || context_sql.contains(" not ")
342 || context_sql.contains("when not ")
343 || context_sql.contains("when ");
344
345 if source_uses_lower {
346 text.replace(" AND ", " and ")
347 .replace(" OR ", " or ")
348 .replace("NOT ", "not ")
349 .replace(" IS NOT NULL", " is not null")
350 .replace(" IS NULL", " is null")
351 .replace(" TRUE", " true")
352 .replace(" FALSE", " false")
353 } else {
354 text.to_string()
355 }
356}
357
358fn null_coalesce_operands<'a>(
360 condition: &'a Expr,
361 then_result: &'a Expr,
362 else_result: Option<&'a Expr>,
363) -> Option<(&'a Expr, &'a Expr)> {
364 if let Expr::IsNull(checked) = condition {
365 Some((checked.as_ref(), then_result))
367 } else if let Expr::IsNotNull(checked) = condition {
368 let fallback = else_result?;
370 Some((checked.as_ref(), fallback))
371 } else {
372 None
373 }
374}
375
376fn column_identity_expr<'a>(
378 condition: &'a Expr,
379 _then_result: &'a Expr,
380 _else_result: Option<&'a Expr>,
381) -> Option<&'a Expr> {
382 if let Expr::IsNull(checked) = condition {
383 Some(checked.as_ref())
385 } else if let Expr::IsNotNull(checked) = condition {
386 Some(checked.as_ref())
388 } else {
389 None
390 }
391}
392
393fn expr_statement_offsets(ctx: &LintContext, expr: &Expr) -> Option<(usize, usize)> {
398 if ctx.statement_range.start > 0 {
399 if let Some((start, end)) = expr_span_offsets(ctx.sql, expr) {
400 if start >= ctx.statement_range.start && end <= ctx.statement_range.end {
401 return Some((
402 start - ctx.statement_range.start,
403 end - ctx.statement_range.start,
404 ));
405 }
406 }
407 }
408
409 if let Some((start, end)) = expr_span_offsets(ctx.statement_sql(), expr) {
410 return Some((start, end));
411 }
412
413 let (start, end) = expr_span_offsets(ctx.sql, expr)?;
414 if start < ctx.statement_range.start || end > ctx.statement_range.end {
415 return None;
416 }
417
418 Some((
419 start - ctx.statement_range.start,
420 end - ctx.statement_range.start,
421 ))
422}
423
424fn expr_span_offsets(sql: &str, expr: &Expr) -> Option<(usize, usize)> {
425 let span = expr.span();
426 if span.start.line == 0 || span.start.column == 0 || span.end.line == 0 || span.end.column == 0
427 {
428 return None;
429 }
430
431 let start = line_col_to_offset(sql, span.start.line as usize, span.start.column as usize)?;
432 let end = line_col_to_offset(sql, span.end.line as usize, span.end.column as usize)?;
433 (end >= start).then_some((start, end))
434}
435
436fn span_contains_comment(ctx: &LintContext, span: Span) -> bool {
437 let from_document_tokens = ctx.with_document_tokens(|tokens| {
438 if tokens.is_empty() {
439 return None;
440 }
441 Some(tokens.iter().any(|token| {
442 let Some((start, end)) = token_with_span_offsets(ctx.sql, token) else {
443 return false;
444 };
445 start >= span.start && end <= span.end && is_comment_token(&token.token)
446 }))
447 });
448
449 if let Some(has_comment) = from_document_tokens {
450 return has_comment;
451 }
452
453 let Some(tokens) = tokenize_statement_with_spans(ctx.statement_sql(), ctx.dialect()) else {
454 return false;
455 };
456 let statement_span = Span::new(
457 span.start.saturating_sub(ctx.statement_range.start),
458 span.end.saturating_sub(ctx.statement_range.start),
459 );
460 tokens.iter().any(|token| {
461 let Some((start, end)) = token_with_span_offsets(ctx.statement_sql(), token) else {
462 return false;
463 };
464 start >= statement_span.start && end <= statement_span.end && is_comment_token(&token.token)
465 })
466}
467
468fn tokenize_statement_with_spans(
469 sql: &str,
470 dialect: crate::types::Dialect,
471) -> Option<Vec<TokenWithSpan>> {
472 let dialect = dialect.to_sqlparser_dialect();
473 let mut tokenizer = Tokenizer::new(dialect.as_ref(), sql);
474 tokenizer.tokenize_with_location().ok()
475}
476
477fn token_with_span_offsets(sql: &str, token: &TokenWithSpan) -> Option<(usize, usize)> {
478 let start = line_col_to_offset(
479 sql,
480 token.span.start.line as usize,
481 token.span.start.column as usize,
482 )?;
483 let end = line_col_to_offset(
484 sql,
485 token.span.end.line as usize,
486 token.span.end.column as usize,
487 )?;
488 Some((start, end))
489}
490
491fn line_col_to_offset(sql: &str, line: usize, column: usize) -> Option<usize> {
492 if line == 0 || column == 0 {
493 return None;
494 }
495
496 let mut current_line = 1usize;
497 let mut line_start = 0usize;
498
499 for (idx, ch) in sql.char_indices() {
500 if current_line == line {
501 break;
502 }
503 if ch == '\n' {
504 current_line += 1;
505 line_start = idx + ch.len_utf8();
506 }
507 }
508 if current_line != line {
509 return None;
510 }
511
512 let mut current_column = 1usize;
513 for (rel_idx, ch) in sql[line_start..].char_indices() {
514 if current_column == column {
515 return Some(line_start + rel_idx);
516 }
517 if ch == '\n' {
518 return None;
519 }
520 current_column += 1;
521 }
522
523 if current_column == column {
524 return Some(sql.len());
525 }
526
527 None
528}
529
530fn is_comment_token(token: &Token) -> bool {
531 matches!(
532 token,
533 Token::Whitespace(Whitespace::SingleLineComment { .. } | Whitespace::MultiLineComment(_))
534 )
535}
536
537#[cfg(test)]
538mod tests {
539 use super::*;
540 use crate::parser::parse_sql;
541 use crate::types::{IssueAutofixApplicability, IssuePatchEdit};
542
543 fn run(sql: &str) -> Vec<Issue> {
544 let statements = parse_sql(sql).expect("parse");
545 let rule = StructureSimpleCase;
546 statements
547 .iter()
548 .enumerate()
549 .flat_map(|(index, statement)| {
550 rule.check(
551 statement,
552 &LintContext {
553 sql,
554 statement_range: 0..sql.len(),
555 statement_index: index,
556 },
557 )
558 })
559 .collect()
560 }
561
562 fn apply_edits(sql: &str, edits: &[IssuePatchEdit]) -> String {
563 let mut output = sql.to_string();
564 let mut ordered = edits.iter().collect::<Vec<_>>();
565 ordered.sort_by_key(|edit| edit.span.start);
566
567 for edit in ordered.into_iter().rev() {
568 output.replace_range(edit.span.start..edit.span.end, &edit.replacement);
569 }
570
571 output
572 }
573
574 #[test]
577 fn pass_case_cannot_be_reduced_1() {
578 let sql = "select fab > 0 as is_fab from fancy_table";
579 assert!(run(sql).is_empty());
580 }
581
582 #[test]
583 fn pass_case_cannot_be_reduced_2() {
584 let sql = "select case when fab > 0 then true end as is_fab from fancy_table";
585 assert!(run(sql).is_empty());
586 }
587
588 #[test]
589 fn pass_case_cannot_be_reduced_3() {
590 let sql = "select case when fab is not null then false end as is_fab from fancy_table";
591 assert!(run(sql).is_empty());
592 }
593
594 #[test]
595 fn pass_case_cannot_be_reduced_4() {
596 let sql = "select case when fab > 0 then true else true end as is_fab from fancy_table";
597 assert!(run(sql).is_empty());
598 }
599
600 #[test]
601 fn pass_case_cannot_be_reduced_5() {
602 let sql =
603 "select case when fab <> 0 then 'just a string' end as fab_category from fancy_table";
604 assert!(run(sql).is_empty());
605 }
606
607 #[test]
608 fn pass_case_cannot_be_reduced_6() {
609 let sql = "select case when fab <> 0 then true when fab < 0 then 'not a bool' end as fab_category from fancy_table";
610 assert!(run(sql).is_empty());
611 }
612
613 #[test]
614 fn pass_single_when_is_null_then_bar() {
615 let sql = "select foo, case when bar is null then bar else '123' end as test from baz";
616 assert!(run(sql).is_empty());
617 }
618
619 #[test]
620 fn pass_is_not_null_then_literal() {
621 let sql = "select foo, case when bar is not null then '123' else bar end as test from baz";
622 assert!(run(sql).is_empty());
623 }
624
625 #[test]
626 fn pass_multiple_when_is_not_null() {
627 let sql = "select foo, case when bar is not null then '123' when foo is not null then '456' else bar end as test from baz";
628 assert!(run(sql).is_empty());
629 }
630
631 #[test]
632 fn pass_compound_condition() {
633 let sql = "select foo, case when bar is not null and abs(foo) > 0 then '123' else bar end as test from baz";
634 assert!(run(sql).is_empty());
635 }
636
637 #[test]
638 fn pass_window_lead_is_null() {
639 let sql = "SELECT dv_runid, CASE WHEN LEAD(dv_startdateutc) OVER (PARTITION BY rowid ORDER BY dv_startdateutc) IS NULL THEN 1 ELSE 0 END AS loadstate FROM d";
640 assert!(run(sql).is_empty());
641 }
642
643 #[test]
644 fn pass_coalesce_is_null() {
645 let sql = "select field_1, field_2, field_3, case when coalesce(field_2, field_3) is null then 1 else 0 end as field_4 from my_table";
646 assert!(run(sql).is_empty());
647 }
648
649 #[test]
650 fn pass_submitted_timestamp() {
651 let sql = "SELECT CASE WHEN item.submitted_timestamp IS NOT NULL THEN item.sitting_id END";
652 assert!(run(sql).is_empty());
653 }
654
655 #[test]
656 fn pass_array_accessor_snowflake() {
657 let sql = "SELECT CASE WHEN genres[0] IS NULL THEN 'x' ELSE genres END AS g FROM table_t";
658 assert!(run(sql).is_empty());
659 }
660
661 #[test]
664 fn fail_unnecessary_case_bool_true_false() {
665 let sql = "select case when fab > 0 then true else false end as is_fab from fancy_table";
666 let issues = run(sql);
667 assert_eq!(issues.len(), 1);
668 assert_eq!(issues[0].code, issue_codes::LINT_ST_002);
669 }
670
671 #[test]
672 fn fail_unnecessary_case_bool_false_true() {
673 let sql = "select case when fab > 0 then false else true end as is_fab from fancy_table";
674 let issues = run(sql);
675 assert_eq!(issues.len(), 1);
676 }
677
678 #[test]
679 fn fail_unnecessary_case_bool_compound_condition() {
680 let sql = "select case when fab > 0 and tot > 0 then true else false end as is_fab from fancy_table";
681 let issues = run(sql);
682 assert_eq!(issues.len(), 1);
683 }
684
685 #[test]
686 fn fail_unnecessary_case_is_null_coalesce() {
687 let sql = "select foo, case when bar is null then '123' else bar end as test from baz";
688 let issues = run(sql);
689 assert_eq!(issues.len(), 1);
690 }
691
692 #[test]
693 fn fail_unnecessary_case_is_not_null_coalesce() {
694 let sql = "select foo, case when bar is not null then bar else '123' end as test from baz";
695 let issues = run(sql);
696 assert_eq!(issues.len(), 1);
697 }
698
699 #[test]
700 fn fail_unnecessary_case_is_null_identity_null_else() {
701 let sql = "select foo, case when bar is null then null else bar end as test from baz";
702 let issues = run(sql);
703 assert_eq!(issues.len(), 1);
704 }
705
706 #[test]
707 fn fail_unnecessary_case_is_not_null_identity_else_null() {
708 let sql = "select foo, case when bar is not null then bar else null end as test from baz";
709 let issues = run(sql);
710 assert_eq!(issues.len(), 1);
711 }
712
713 #[test]
714 fn fail_unnecessary_case_is_not_null_identity_no_else() {
715 let sql = "select foo, case when bar is not null then bar end as test from baz";
716 let issues = run(sql);
717 assert_eq!(issues.len(), 1);
718 }
719
720 #[test]
721 fn fail_is_null_then_false_else_true() {
722 let sql = "select case when perks.perk is null then false else true end as perk_redeemed from subscriptions_xf";
723 let issues = run(sql);
724 assert_eq!(issues.len(), 1);
725 }
726
727 #[test]
730 fn autofix_bool_true_false() {
731 let sql = "select case when fab > 0 then true else false end as is_fab from fancy_table";
732 let issues = run(sql);
733 assert_eq!(issues.len(), 1);
734 let autofix = issues[0].autofix.as_ref().expect("expected autofix");
735 assert_eq!(autofix.applicability, IssueAutofixApplicability::Safe);
736 let fixed = apply_edits(sql, &autofix.edits);
737 assert_eq!(
738 fixed,
739 "select coalesce(fab > 0, false) as is_fab from fancy_table"
740 );
741 }
742
743 #[test]
744 fn autofix_bool_false_true() {
745 let sql = "select case when fab > 0 then false else true end as is_fab from fancy_table";
746 let issues = run(sql);
747 assert_eq!(issues.len(), 1);
748 let autofix = issues[0].autofix.as_ref().expect("expected autofix");
749 let fixed = apply_edits(sql, &autofix.edits);
750 assert_eq!(
751 fixed,
752 "select not coalesce(fab > 0, false) as is_fab from fancy_table"
753 );
754 }
755
756 #[test]
757 fn autofix_is_null_coalesce() {
758 let sql = "select foo, case when bar is null then '123' else bar end as test from baz";
759 let issues = run(sql);
760 assert_eq!(issues.len(), 1);
761 let autofix = issues[0].autofix.as_ref().expect("expected autofix");
762 let fixed = apply_edits(sql, &autofix.edits);
763 assert_eq!(fixed, "select foo, coalesce(bar, '123') as test from baz");
764 }
765
766 #[test]
767 fn autofix_is_not_null_coalesce() {
768 let sql = "select foo, case when bar is not null then bar else '123' end as test from baz";
769 let issues = run(sql);
770 assert_eq!(issues.len(), 1);
771 let autofix = issues[0].autofix.as_ref().expect("expected autofix");
772 let fixed = apply_edits(sql, &autofix.edits);
773 assert_eq!(fixed, "select foo, coalesce(bar, '123') as test from baz");
774 }
775
776 #[test]
777 fn autofix_is_null_then_null_identity() {
778 let sql = "select foo, case when bar is null then null else bar end as test from baz";
779 let issues = run(sql);
780 assert_eq!(issues.len(), 1);
781 let autofix = issues[0].autofix.as_ref().expect("expected autofix");
782 let fixed = apply_edits(sql, &autofix.edits);
783 assert_eq!(fixed, "select foo, bar as test from baz");
784 }
785
786 #[test]
787 fn autofix_is_not_null_identity_no_else() {
788 let sql = "select foo, case when bar is not null then bar end as test from baz";
789 let issues = run(sql);
790 assert_eq!(issues.len(), 1);
791 let autofix = issues[0].autofix.as_ref().expect("expected autofix");
792 let fixed = apply_edits(sql, &autofix.edits);
793 assert_eq!(fixed, "select foo, bar as test from baz");
794 }
795
796 #[test]
797 fn autofix_bool_compound_preserves_keyword_case() {
798 let sql =
799 "select case when fab > 0 and tot > 0 then true else false end as is_fab from fancy_table";
800 let issues = run(sql);
801 assert_eq!(issues.len(), 1);
802 let autofix = issues[0].autofix.as_ref().expect("expected autofix");
803 let fixed = apply_edits(sql, &autofix.edits);
804 assert_eq!(
805 fixed,
806 "select coalesce(fab > 0 and tot > 0, false) as is_fab from fancy_table"
807 );
808 }
809
810 #[test]
811 fn autofix_bool_negated_compound_preserves_keyword_case() {
812 let sql =
813 "select case when fab > 0 and tot > 0 then false else true end as is_fab from fancy_table";
814 let issues = run(sql);
815 assert_eq!(issues.len(), 1);
816 let autofix = issues[0].autofix.as_ref().expect("expected autofix");
817 let fixed = apply_edits(sql, &autofix.edits);
818 assert_eq!(
819 fixed,
820 "select not coalesce(fab > 0 and tot > 0, false) as is_fab from fancy_table"
821 );
822 }
823
824 #[test]
825 fn autofix_multiline_compound_preserves_keyword_case() {
826 let sql = "select\n case\n when fab > 0 and tot > 0 then true else false end as is_fab\nfrom fancy_table";
827 let issues = run(sql);
828 assert_eq!(issues.len(), 1);
829 let autofix = issues[0].autofix.as_ref().expect("expected autofix");
830 let fixed = apply_edits(sql, &autofix.edits);
831 assert_eq!(
832 fixed,
833 "select\n coalesce(fab > 0 and tot > 0, false) as is_fab\nfrom fancy_table"
834 );
835 }
836
837 #[test]
838 fn autofix_multiline_negated_or_preserves_keyword_case() {
839 let sql = "select\n case\n when not fab > 0 or tot > 0 then false else true end as is_fab\nfrom fancy_table";
840 let issues = run(sql);
841 assert_eq!(issues.len(), 1);
842 let autofix = issues[0].autofix.as_ref().expect("expected autofix");
843 let fixed = apply_edits(sql, &autofix.edits);
844 assert_eq!(
845 fixed,
846 "select\n not coalesce(not fab > 0 or tot > 0, false) as is_fab\nfrom fancy_table"
847 );
848 }
849
850 #[test]
851 fn comment_in_case_downgrades_autofix_to_unsafe() {
852 let sql =
853 "select case when fab > 0 /*keep*/ then true else false end as is_fab from fancy_table";
854 let issues = run(sql);
855 assert_eq!(issues.len(), 1);
856 let autofix = issues[0]
857 .autofix
858 .as_ref()
859 .expect("expected autofix metadata");
860 assert_eq!(autofix.applicability, IssueAutofixApplicability::Unsafe);
861 }
862
863 #[test]
864 fn autofix_comment_after_case_keyword_uses_display_fallback() {
865 let sql = "select\n subscriptions_xf.metadata_migrated,\n\n case -- BEFORE ST02 FIX\n when perks.perk is null then false\n else true\n end as perk_redeemed,\n\n perks.received_at as perk_received_at\n\nfrom subscriptions_xf\n";
866 let issues = run(sql);
867 assert_eq!(issues.len(), 1);
868 let autofix = issues[0]
869 .autofix
870 .as_ref()
871 .expect("expected autofix metadata");
872 assert_eq!(autofix.applicability, IssueAutofixApplicability::Unsafe);
873 let fixed = apply_edits(sql, &autofix.edits);
874 assert_eq!(
875 fixed,
876 "select\n subscriptions_xf.metadata_migrated,\n\n not coalesce(perks.perk is null, false) as perk_redeemed,\n\n perks.received_at as perk_received_at\n\nfrom subscriptions_xf\n"
877 );
878 }
879
880 #[test]
881 fn statementless_template_case_is_still_reported_without_autofix() {
882 let sql = "select\n foo,\n case\n when\n bar is null then {{ result }}\n else bar\n end as test\nfrom baz;\n";
883 let synthetic = parse_sql("SELECT 1").expect("parse");
884 let rule = StructureSimpleCase;
885 let issues = rule.check(
886 &synthetic[0],
887 &LintContext {
888 sql,
889 statement_range: 0..sql.len(),
890 statement_index: 0,
891 },
892 );
893 assert_eq!(issues.len(), 1);
894 assert_eq!(issues[0].code, issue_codes::LINT_ST_002);
895 assert!(
896 issues[0].autofix.is_none(),
897 "template fallback should report detection-only without copying templated code"
898 );
899 }
900}