1use sql_dialect_fmt_parser::{parse_with_dialect, SyntaxKind, SyntaxNode};
32use sql_dialect_fmt_syntax::keyword_kind;
33use sql_dialect_fmt_text::LineIndex;
34
35pub use sql_dialect_fmt_parser::Dialect;
36
37#[derive(Clone, Copy, Debug, PartialEq, Eq)]
39pub struct LintOptions {
40 pub select_wildcard: bool,
42 pub large_in_list: bool,
44 pub unsupported_embedded_language: bool,
47 pub delete_without_where: bool,
49 pub update_without_where: bool,
51 pub comma_join: bool,
53 pub order_by_ordinal: bool,
55 pub large_in_list_threshold: usize,
57}
58
59impl Default for LintOptions {
60 fn default() -> Self {
61 Self {
62 select_wildcard: true,
63 large_in_list: true,
64 unsupported_embedded_language: true,
65 delete_without_where: true,
66 update_without_where: true,
67 comma_join: true,
68 order_by_ordinal: true,
69 large_in_list_threshold: 100,
70 }
71 }
72}
73
74#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
76pub enum LintCode {
77 SelectWildcard,
78 LargeInList,
79 UnsupportedEmbeddedLanguage,
80 DeleteWithoutWhere,
81 UpdateWithoutWhere,
82 CommaJoin,
83 OrderByOrdinal,
84}
85
86impl LintCode {
87 pub fn as_str(self) -> &'static str {
89 match self {
90 LintCode::SelectWildcard => "SDF001",
91 LintCode::LargeInList => "SDF002",
92 LintCode::UnsupportedEmbeddedLanguage => "SDF003",
93 LintCode::DeleteWithoutWhere => "SDF004",
94 LintCode::UpdateWithoutWhere => "SDF005",
95 LintCode::CommaJoin => "SDF006",
96 LintCode::OrderByOrdinal => "SDF007",
97 }
98 }
99
100 pub fn from_code(value: &str) -> Option<Self> {
102 match value {
103 "SDF001" => Some(Self::SelectWildcard),
104 "SDF002" => Some(Self::LargeInList),
105 "SDF003" => Some(Self::UnsupportedEmbeddedLanguage),
106 "SDF004" => Some(Self::DeleteWithoutWhere),
107 "SDF005" => Some(Self::UpdateWithoutWhere),
108 "SDF006" => Some(Self::CommaJoin),
109 "SDF007" => Some(Self::OrderByOrdinal),
110 _ => None,
111 }
112 }
113}
114
115#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
117pub enum LintSeverity {
118 Warning,
119 Error,
120}
121
122#[derive(Clone, Debug, PartialEq, Eq)]
124pub struct LintDiagnostic {
125 pub code: LintCode,
127 pub severity: LintSeverity,
129 pub message: String,
131 pub range: std::ops::Range<usize>,
133}
134
135pub fn lint(text: &str) -> Vec<LintDiagnostic> {
137 lint_with_options(text, LintOptions::default())
138}
139
140pub fn lint_with_options(text: &str, options: LintOptions) -> Vec<LintDiagnostic> {
142 lint_with_dialect(text, Dialect::Snowflake, options)
143}
144
145pub fn lint_with_dialect(
148 text: &str,
149 dialect: Dialect,
150 options: LintOptions,
151) -> Vec<LintDiagnostic> {
152 let context = LintContext {
153 tokens: lex_tokens(text, dialect),
154 root: parse_with_dialect(text, dialect).syntax(),
155 };
156
157 let mut diagnostics = Vec::new();
158 run_rule::<SelectWildcardRule>(&context, &options, &mut diagnostics);
159 run_rule::<LargeInListRule>(&context, &options, &mut diagnostics);
160 run_rule::<UnsupportedEmbeddedLanguageRule>(&context, &options, &mut diagnostics);
161 run_rule::<DeleteWithoutWhereRule>(&context, &options, &mut diagnostics);
162 run_rule::<UpdateWithoutWhereRule>(&context, &options, &mut diagnostics);
163 run_rule::<CommaJoinRule>(&context, &options, &mut diagnostics);
164 run_rule::<OrderByOrdinalRule>(&context, &options, &mut diagnostics);
165
166 let index = LineIndex::new(text);
167 diagnostics.retain(|diagnostic| !is_suppressed(text, &index, diagnostic));
168 diagnostics.sort_by_key(|diagnostic| (diagnostic.range.start, diagnostic.range.end));
169 diagnostics
170}
171
172struct LintContext<'a> {
174 tokens: Vec<LintToken<'a>>,
175 root: SyntaxNode,
176}
177
178fn run_rule<R: LintRule>(
179 context: &LintContext<'_>,
180 options: &LintOptions,
181 out: &mut Vec<LintDiagnostic>,
182) {
183 if R::enabled(options) {
184 R::check(context, options, out);
185 }
186}
187
188trait LintRule {
189 const CODE: LintCode;
190
191 fn enabled(options: &LintOptions) -> bool;
192
193 fn check(context: &LintContext<'_>, options: &LintOptions, out: &mut Vec<LintDiagnostic>);
194
195 fn warning(range: std::ops::Range<usize>, message: &str) -> LintDiagnostic {
196 LintDiagnostic {
197 code: Self::CODE,
198 severity: LintSeverity::Warning,
199 message: message.to_string(),
200 range,
201 }
202 }
203}
204
205#[derive(Clone, Copy, Debug, PartialEq, Eq)]
209enum TokenClass {
210 Trivia,
212 Keyword,
214 Word,
216 DollarString,
218 Other,
220}
221
222#[derive(Clone, Debug)]
223struct LintToken<'a> {
224 class: TokenClass,
225 text: &'a str,
226 range: std::ops::Range<usize>,
227}
228
229fn lex_tokens(text: &str, dialect: Dialect) -> Vec<LintToken<'_>> {
230 let lexed = sql_dialect_fmt_lexer::tokenize_for_dialect(text, dialect);
231 lexed
232 .tokens
233 .into_iter()
234 .scan(0usize, |offset, token| {
235 let start = *offset;
236 *offset += token.text.len();
237 Some(LintToken {
238 class: classify(token.kind, token.text),
239 text: token.text,
240 range: start..*offset,
241 })
242 })
243 .collect()
244}
245
246fn classify(kind: SyntaxKind, text: &str) -> TokenClass {
247 if kind.is_trivia() {
248 return TokenClass::Trivia;
249 }
250 if kind.is_keyword()
251 || kind == SyntaxKind::CONTEXTUAL_KEYWORD
252 || (kind == SyntaxKind::IDENT && keyword_kind(text).is_some())
253 {
254 return TokenClass::Keyword;
255 }
256 match kind {
257 SyntaxKind::IDENT => TokenClass::Word,
258 SyntaxKind::DOLLAR_STRING => TokenClass::DollarString,
259 _ => TokenClass::Other,
260 }
261}
262
263fn is_significant(token: &LintToken<'_>) -> bool {
264 token.class != TokenClass::Trivia
265}
266
267fn is_keyword(token: &LintToken<'_>, expected: &str) -> bool {
268 token.class == TokenClass::Keyword && token.text.eq_ignore_ascii_case(expected)
269}
270
271struct SelectWildcardRule;
276
277impl LintRule for SelectWildcardRule {
278 const CODE: LintCode = LintCode::SelectWildcard;
279
280 fn enabled(options: &LintOptions) -> bool {
281 options.select_wildcard
282 }
283
284 fn check(context: &LintContext<'_>, _options: &LintOptions, out: &mut Vec<LintDiagnostic>) {
285 let mut in_select_list = false;
286 let mut paren_depth = 0usize;
287 let mut previous_significant: Option<&str> = None;
288
289 for token in context.tokens.iter().filter(|token| is_significant(token)) {
290 if is_keyword(token, "select") {
291 in_select_list = true;
292 paren_depth = 0;
293 previous_significant = Some(token.text);
294 continue;
295 }
296
297 if in_select_list {
298 match token.text {
299 "(" => paren_depth += 1,
300 ")" => paren_depth = paren_depth.saturating_sub(1),
301 ";" if paren_depth == 0 => in_select_list = false,
302 _ => {
303 if paren_depth == 0 && is_keyword(token, "from") {
304 in_select_list = false;
305 } else if paren_depth == 0
306 && token.text == "*"
307 && previous_significant.is_some_and(is_wildcard_prefix)
308 {
309 out.push(Self::warning(
310 token.range.clone(),
311 "avoid SELECT * in shared SQL; list columns explicitly",
312 ));
313 }
314 }
315 }
316 }
317
318 previous_significant = Some(token.text);
319 }
320 }
321}
322
323fn is_wildcard_prefix(text: &str) -> bool {
324 matches!(text, "," | ".")
325 || text.eq_ignore_ascii_case("select")
326 || text.eq_ignore_ascii_case("distinct")
327 || text.eq_ignore_ascii_case("all")
328}
329
330struct LargeInListRule;
335
336impl LintRule for LargeInListRule {
337 const CODE: LintCode = LintCode::LargeInList;
338
339 fn enabled(options: &LintOptions) -> bool {
340 options.large_in_list
341 }
342
343 fn check(context: &LintContext<'_>, options: &LintOptions, out: &mut Vec<LintDiagnostic>) {
344 #[derive(Debug)]
345 struct InList {
346 start: usize,
347 depth: usize,
348 commas: usize,
349 saw_top_level_item: bool,
350 possible_subquery: bool,
351 }
352
353 let mut pending_in: Option<usize> = None;
354 let mut list: Option<InList> = None;
355
356 for token in context.tokens.iter().filter(|token| is_significant(token)) {
357 let mut close_list_at: Option<usize> = None;
358 if let Some(active) = list.as_mut() {
359 match token.text {
360 "(" => active.depth += 1,
361 ")" => {
362 active.depth = active.depth.saturating_sub(1);
363 if active.depth == 0 {
364 close_list_at = Some(token.range.end);
365 }
366 }
367 "," if active.depth == 1 => active.commas += 1,
368 _ if active.depth == 1 && !active.saw_top_level_item => {
369 active.saw_top_level_item = true;
370 if is_keyword(token, "select") || is_keyword(token, "with") {
371 active.possible_subquery = true;
372 }
373 }
374 _ => {}
375 }
376
377 if let Some(end) = close_list_at {
378 if let Some(active) = list.take() {
379 let item_count = if active.saw_top_level_item {
380 active.commas + 1
381 } else {
382 0
383 };
384 if !active.possible_subquery && item_count > options.large_in_list_threshold
385 {
386 out.push(Self::warning(
387 active.start..end,
388 "large IN list; prefer a temp table, CTE, or semi-join when practical",
389 ));
390 }
391 }
392 }
393 continue;
394 }
395
396 if let Some(start) = pending_in.take() {
397 if token.text == "(" {
398 list = Some(InList {
399 start,
400 depth: 1,
401 commas: 0,
402 saw_top_level_item: false,
403 possible_subquery: false,
404 });
405 continue;
406 }
407 }
408
409 if is_keyword(token, "in") {
410 pending_in = Some(token.range.start);
411 }
412 }
413 }
414}
415
416struct UnsupportedEmbeddedLanguageRule;
421
422impl LintRule for UnsupportedEmbeddedLanguageRule {
423 const CODE: LintCode = LintCode::UnsupportedEmbeddedLanguage;
424
425 fn enabled(options: &LintOptions) -> bool {
426 options.unsupported_embedded_language
427 }
428
429 fn check(context: &LintContext<'_>, _options: &LintOptions, out: &mut Vec<LintDiagnostic>) {
430 let mut expect_language_name = false;
431 let mut language_name: Option<(&str, std::ops::Range<usize>)> = None;
432 let mut saw_as_after_language = false;
433
434 for token in &context.tokens {
435 if token.class == TokenClass::Trivia {
436 continue;
437 }
438 if token.text == ";" {
439 expect_language_name = false;
440 language_name = None;
441 saw_as_after_language = false;
442 continue;
443 }
444 match token.class {
445 TokenClass::DollarString => {
446 if saw_as_after_language {
447 if let Some((word, range)) = language_name.take() {
448 if !is_supported_embedded_language(word) {
449 out.push(Self::warning(
450 range,
451 &format!(
452 "unsupported embedded language {word}; expected SQL, JAVASCRIPT, PYTHON, JAVA, or SCALA"
453 ),
454 ));
455 }
456 }
457 }
458 expect_language_name = false;
459 saw_as_after_language = false;
460 }
461 TokenClass::Keyword if token.text.eq_ignore_ascii_case("language") => {
462 expect_language_name = true;
463 language_name = None;
464 saw_as_after_language = false;
465 }
466 TokenClass::Keyword | TokenClass::Word if expect_language_name => {
467 language_name = Some((token.text, token.range.clone()));
468 expect_language_name = false;
469 }
470 TokenClass::Keyword
471 if language_name.is_some() && token.text.eq_ignore_ascii_case("as") =>
472 {
473 saw_as_after_language = true;
474 }
475 _ => {
476 expect_language_name = false;
477 }
478 }
479 }
480 }
481}
482
483fn is_supported_embedded_language(word: &str) -> bool {
484 ["SQL", "JAVASCRIPT", "PYTHON", "JAVA", "SCALA"]
485 .iter()
486 .any(|candidate| candidate.eq_ignore_ascii_case(word))
487}
488
489struct DeleteWithoutWhereRule;
494
495impl LintRule for DeleteWithoutWhereRule {
496 const CODE: LintCode = LintCode::DeleteWithoutWhere;
497
498 fn enabled(options: &LintOptions) -> bool {
499 options.delete_without_where
500 }
501
502 fn check(context: &LintContext<'_>, _options: &LintOptions, out: &mut Vec<LintDiagnostic>) {
503 check_statement_without_where(
504 &context.root,
505 SyntaxKind::DELETE_STMT,
506 "DELETE without WHERE affects every row; add a WHERE clause or use TRUNCATE",
507 Self::warning,
508 out,
509 );
510 }
511}
512
513struct UpdateWithoutWhereRule;
514
515impl LintRule for UpdateWithoutWhereRule {
516 const CODE: LintCode = LintCode::UpdateWithoutWhere;
517
518 fn enabled(options: &LintOptions) -> bool {
519 options.update_without_where
520 }
521
522 fn check(context: &LintContext<'_>, _options: &LintOptions, out: &mut Vec<LintDiagnostic>) {
523 check_statement_without_where(
524 &context.root,
525 SyntaxKind::UPDATE_STMT,
526 "UPDATE without WHERE affects every row; add a WHERE clause",
527 Self::warning,
528 out,
529 );
530 }
531}
532
533fn check_statement_without_where(
537 root: &SyntaxNode,
538 kind: SyntaxKind,
539 message: &str,
540 warning: impl Fn(std::ops::Range<usize>, &str) -> LintDiagnostic,
541 out: &mut Vec<LintDiagnostic>,
542) {
543 for statement in root.descendants().filter(|node| node.kind() == kind) {
544 let has_where = statement
545 .children()
546 .any(|child| child.kind() == SyntaxKind::WHERE_CLAUSE);
547 if has_where {
548 continue;
549 }
550 if let Some(range) = first_significant_token_range(&statement) {
551 out.push(warning(range, message));
552 }
553 }
554}
555
556struct CommaJoinRule;
561
562impl LintRule for CommaJoinRule {
563 const CODE: LintCode = LintCode::CommaJoin;
564
565 fn enabled(options: &LintOptions) -> bool {
566 options.comma_join
567 }
568
569 fn check(context: &LintContext<'_>, _options: &LintOptions, out: &mut Vec<LintDiagnostic>) {
570 for from_clause in context
571 .root
572 .descendants()
573 .filter(|node| node.kind() == SyntaxKind::FROM_CLAUSE)
574 {
575 let elements: Vec<_> = from_clause.children_with_tokens().collect();
576 for (position, element) in elements.iter().enumerate() {
577 let Some(comma) = element
578 .as_token()
579 .filter(|token| token.kind() == SyntaxKind::COMMA)
580 else {
581 continue;
582 };
583 if joined_table_ref_is_lateral_or_table_function(&elements[position + 1..]) {
586 continue;
587 }
588 let start: usize = comma.text_range().start().into();
589 let end: usize = comma.text_range().end().into();
590 out.push(Self::warning(
591 start..end,
592 "comma join is an implicit cross join; use an explicit JOIN ... ON instead",
593 ));
594 }
595 }
596 }
597}
598
599fn joined_table_ref_is_lateral_or_table_function(
602 rest: &[sql_dialect_fmt_syntax::SyntaxElement],
603) -> bool {
604 let Some(node) = rest.iter().find_map(|element| element.as_node()) else {
605 return false;
606 };
607 if node.kind() != SyntaxKind::TABLE_REF {
608 return false;
609 }
610 node.descendants_with_tokens()
611 .filter_map(|child| child.into_token())
612 .find(|token| !token.kind().is_trivia())
613 .is_some_and(|token| matches!(token.kind(), SyntaxKind::LATERAL_KW | SyntaxKind::TABLE_KW))
614}
615
616struct OrderByOrdinalRule;
621
622impl LintRule for OrderByOrdinalRule {
623 const CODE: LintCode = LintCode::OrderByOrdinal;
624
625 fn enabled(options: &LintOptions) -> bool {
626 options.order_by_ordinal
627 }
628
629 fn check(context: &LintContext<'_>, _options: &LintOptions, out: &mut Vec<LintDiagnostic>) {
630 for order_by in context
631 .root
632 .descendants()
633 .filter(|node| node.kind() == SyntaxKind::ORDER_BY_CLAUSE)
634 {
635 if order_by.parent().is_some_and(|parent| {
638 matches!(
639 parent.kind(),
640 SyntaxKind::WINDOW_SPEC | SyntaxKind::WITHIN_GROUP
641 )
642 }) {
643 continue;
644 }
645 for item in order_by
646 .children()
647 .filter(|node| node.kind() == SyntaxKind::ORDER_BY_ITEM)
648 {
649 let Some(range) = ordinal_literal_range(&item) else {
650 continue;
651 };
652 out.push(Self::warning(
653 range,
654 "ORDER BY ordinal is fragile; order by the column name or expression instead",
655 ));
656 }
657 }
658 }
659}
660
661fn ordinal_literal_range(item: &SyntaxNode) -> Option<std::ops::Range<usize>> {
663 let expr = item.children().next()?;
664 if expr.kind() != SyntaxKind::LITERAL {
665 return None;
666 }
667 let token = expr
668 .children_with_tokens()
669 .filter_map(|element| element.into_token())
670 .find(|token| !token.kind().is_trivia())?;
671 if token.kind() != SyntaxKind::INT_NUMBER {
672 return None;
673 }
674 let start: usize = token.text_range().start().into();
675 let end: usize = token.text_range().end().into();
676 Some(start..end)
677}
678
679fn first_significant_token_range(node: &SyntaxNode) -> Option<std::ops::Range<usize>> {
680 node.descendants_with_tokens()
681 .filter_map(|element| element.into_token())
682 .find(|token| !token.kind().is_trivia())
683 .map(|token| {
684 let start: usize = token.text_range().start().into();
685 let end: usize = token.text_range().end().into();
686 start..end
687 })
688}
689
690fn is_suppressed(text: &str, index: &LineIndex<'_>, diagnostic: &LintDiagnostic) -> bool {
695 let line = index.utf8_position(diagnostic.range.start).line as usize;
696 if line == 0 {
697 return false;
698 }
699 let Some(previous_line) = text.lines().nth(line - 1) else {
700 return false;
701 };
702 line_suppresses_code(previous_line, diagnostic.code)
703}
704
705fn line_suppresses_code(line: &str, code: LintCode) -> bool {
706 let trimmed = line.trim_start();
707 let Some(comment) = trimmed.strip_prefix("--") else {
708 return false;
709 };
710 let Some((_, rest)) = comment.split_once("sql-dialect-fmt: disable-next-line") else {
711 return false;
712 };
713 let rest = rest.trim();
714 if rest.is_empty() {
715 return true;
716 }
717 rest.split(|ch: char| ch.is_whitespace() || ch == ',' || ch == ';')
718 .filter(|part| !part.is_empty())
719 .any(|part| {
720 part.eq_ignore_ascii_case(code.as_str())
721 || part.eq_ignore_ascii_case("all")
722 || part.eq_ignore_ascii_case("lint")
723 })
724}
725
726#[cfg(test)]
727mod tests {
728 use super::*;
729
730 fn codes(text: &str) -> Vec<&'static str> {
731 lint(text)
732 .into_iter()
733 .map(|diagnostic| diagnostic.code.as_str())
734 .collect()
735 }
736
737 fn find(text: &str, code: LintCode) -> LintDiagnostic {
738 lint(text)
739 .into_iter()
740 .find(|diagnostic| diagnostic.code == code)
741 .unwrap_or_else(|| panic!("expected a {} finding in {text:?}", code.as_str()))
742 }
743
744 #[test]
745 fn select_wildcard_is_flagged_at_the_star() {
746 let text = "SELECT * FROM t;";
747 let diagnostic = find(text, LintCode::SelectWildcard);
748 assert_eq!(diagnostic.severity, LintSeverity::Warning);
749 assert_eq!(
750 diagnostic.range,
751 text.find('*').unwrap()..text.find('*').unwrap() + 1
752 );
753 }
754
755 #[test]
756 fn select_wildcard_ignores_function_stars() {
757 assert!(codes("SELECT count(*) FROM t;").is_empty());
758 }
759
760 #[test]
761 fn large_in_list_respects_threshold_option() {
762 let text = "SELECT id FROM t WHERE id IN (1, 2, 3);";
763 assert!(codes(text).is_empty());
764 let options = LintOptions {
765 large_in_list_threshold: 2,
766 ..LintOptions::default()
767 };
768 let diagnostics = lint_with_options(text, options);
769 assert_eq!(diagnostics.len(), 1);
770 assert_eq!(diagnostics[0].code, LintCode::LargeInList);
771 assert_eq!(diagnostics[0].range.start, text.find("IN").unwrap());
772 }
773
774 #[test]
775 fn in_subquery_is_not_a_large_in_list() {
776 let options = LintOptions {
777 large_in_list_threshold: 0,
778 ..LintOptions::default()
779 };
780 assert!(lint_with_options(
781 "SELECT id FROM t WHERE id IN (SELECT id FROM src);",
782 options
783 )
784 .is_empty());
785 }
786
787 #[test]
788 fn unsupported_embedded_language_is_flagged() {
789 let text = "CREATE FUNCTION f() RETURNS STRING LANGUAGE RUBY AS $$x$$;";
790 let diagnostic = find(text, LintCode::UnsupportedEmbeddedLanguage);
791 assert!(diagnostic.message.contains("RUBY"));
792 let start = text.find("RUBY").unwrap();
793 assert_eq!(diagnostic.range, start..start + 4);
794 }
795
796 #[test]
797 fn embedded_language_does_not_fire_for_plain_columns_or_dynamic_sql() {
798 assert!(codes("SELECT language FROM t;").is_empty());
799 assert!(codes("EXECUTE IMMEDIATE $$ SELECT 1 $$;").is_empty());
800 }
801
802 #[test]
803 fn delete_without_where_is_flagged_at_the_delete_keyword() {
804 let text = "DELETE FROM t;";
805 let diagnostic = find(text, LintCode::DeleteWithoutWhere);
806 assert_eq!(diagnostic.severity, LintSeverity::Warning);
807 assert_eq!(diagnostic.range, 0..6);
808 }
809
810 #[test]
811 fn delete_with_where_is_clean() {
812 assert!(codes("DELETE FROM t WHERE id = 1;").is_empty());
813 assert!(codes("DELETE FROM t USING s WHERE t.id = s.id;").is_empty());
814 }
815
816 #[test]
817 fn merge_matched_delete_is_not_a_bare_delete() {
818 let text = "MERGE INTO t USING s ON t.id = s.id WHEN MATCHED THEN DELETE;";
819 assert!(codes(text).is_empty());
820 }
821
822 #[test]
823 fn delete_inside_a_scripting_block_is_flagged() {
824 let text = "BEGIN\n DELETE FROM t;\nEND;";
825 let diagnostic = find(text, LintCode::DeleteWithoutWhere);
826 assert_eq!(diagnostic.range.start, text.find("DELETE").unwrap());
827 }
828
829 #[test]
830 fn update_without_where_is_flagged() {
831 let text = "SELECT 1;\nUPDATE t SET a = 1;";
832 let diagnostic = find(text, LintCode::UpdateWithoutWhere);
833 assert_eq!(diagnostic.range.start, text.find("UPDATE").unwrap());
834 }
835
836 #[test]
837 fn update_with_where_is_clean() {
838 assert!(codes("UPDATE t SET a = 1 WHERE b = 2;").is_empty());
839 }
840
841 #[test]
842 fn merge_matched_update_is_not_a_bare_update() {
843 let text = "MERGE INTO t USING s ON t.id = s.id WHEN MATCHED THEN UPDATE SET t.a = s.a;";
844 assert!(codes(text).is_empty());
845 }
846
847 #[test]
848 fn comma_join_is_flagged_at_the_comma() {
849 let text = "SELECT a FROM t1, t2 WHERE t1.id = t2.id;";
850 let diagnostic = find(text, LintCode::CommaJoin);
851 let comma = text.find(',').unwrap();
852 assert_eq!(diagnostic.range, comma..comma + 1);
853 }
854
855 #[test]
856 fn explicit_joins_and_argument_commas_are_clean() {
857 assert!(codes("SELECT a FROM t1 JOIN t2 ON t1.id = t2.id;").is_empty());
858 assert!(codes("SELECT a FROM t1 CROSS JOIN t2;").is_empty());
859 assert!(codes("SELECT d FROM TABLE(f(x, y));").is_empty());
860 assert!(codes("SELECT greatest(a, b) FROM t;").is_empty());
861 }
862
863 #[test]
864 fn lateral_and_table_function_commas_are_clean() {
865 assert!(codes("SELECT a FROM t, LATERAL FLATTEN(input => t.v) f;").is_empty());
866 assert!(codes("SELECT b FROM t1, TABLE(GENERATOR(ROWCOUNT => 10));").is_empty());
867 }
868
869 #[test]
870 fn multiple_comma_joins_produce_one_finding_each() {
871 let diagnostics = lint("SELECT a FROM t1, t2, t3;");
872 let commas = diagnostics
873 .iter()
874 .filter(|diagnostic| diagnostic.code == LintCode::CommaJoin)
875 .count();
876 assert_eq!(commas, 2);
877 }
878
879 #[test]
880 fn order_by_ordinal_is_flagged_per_item() {
881 let text = "SELECT a, b FROM t ORDER BY 1 DESC, b ASC;";
882 let diagnostics = lint(text);
883 let ordinals: Vec<_> = diagnostics
884 .iter()
885 .filter(|diagnostic| diagnostic.code == LintCode::OrderByOrdinal)
886 .collect();
887 assert_eq!(ordinals.len(), 1);
888 let one = text.find("BY 1").unwrap() + 3;
889 assert_eq!(ordinals[0].range, one..one + 1);
890 }
891
892 #[test]
893 fn order_by_expressions_and_limits_are_clean() {
894 assert!(codes("SELECT a FROM t ORDER BY a;").is_empty());
895 assert!(codes("SELECT a FROM t ORDER BY a + 1;").is_empty());
896 assert!(codes("SELECT a FROM t ORDER BY a LIMIT 1;").is_empty());
897 }
898
899 #[test]
900 fn window_and_within_group_order_by_are_not_ordinals() {
901 assert!(codes("SELECT row_number() OVER (ORDER BY 1) FROM t;").is_empty());
902 assert!(codes("SELECT listagg(a) WITHIN GROUP (ORDER BY 1) FROM t;").is_empty());
903 }
904
905 #[test]
906 fn each_new_rule_is_gated_by_its_option() {
907 for (text, disabled) in [
908 (
909 "DELETE FROM t;",
910 LintOptions {
911 delete_without_where: false,
912 ..LintOptions::default()
913 },
914 ),
915 (
916 "UPDATE t SET a = 1;",
917 LintOptions {
918 update_without_where: false,
919 ..LintOptions::default()
920 },
921 ),
922 (
923 "SELECT a FROM t1, t2;",
924 LintOptions {
925 comma_join: false,
926 ..LintOptions::default()
927 },
928 ),
929 (
930 "SELECT a FROM t ORDER BY 1;",
931 LintOptions {
932 order_by_ordinal: false,
933 ..LintOptions::default()
934 },
935 ),
936 ] {
937 assert!(!lint(text).is_empty(), "{text} should lint by default");
938 assert!(
939 lint_with_options(text, disabled).is_empty(),
940 "{text} should be clean when its rule is disabled"
941 );
942 }
943 }
944
945 #[test]
946 fn databricks_dialect_is_honored() {
947 let diagnostics = lint_with_dialect(
948 "DELETE FROM `my schema`.`t`;",
949 Dialect::Databricks,
950 LintOptions::default(),
951 );
952 assert_eq!(diagnostics.len(), 1);
953 assert_eq!(diagnostics[0].code, LintCode::DeleteWithoutWhere);
954 }
955
956 #[test]
957 fn findings_are_sorted_by_position() {
958 let text = "SELECT * FROM t1, t2 ORDER BY 1;";
959 let diagnostics = lint(text);
960 let starts: Vec<_> = diagnostics
961 .iter()
962 .map(|diagnostic| diagnostic.range.start)
963 .collect();
964 let mut sorted = starts.clone();
965 sorted.sort_unstable();
966 assert_eq!(starts, sorted);
967 assert_eq!(diagnostics.len(), 3);
968 }
969
970 #[test]
971 fn disable_next_line_suppresses_specific_lint_code() {
972 let text = "-- sql-dialect-fmt: disable-next-line SDF001\nSELECT * FROM t;";
973 assert!(lint(text).is_empty());
974 }
975
976 #[test]
977 fn disable_next_line_suppresses_all_lint_codes_when_code_is_omitted() {
978 let text = "-- sql-dialect-fmt: disable-next-line\nSELECT * FROM t;";
979 assert!(lint(text).is_empty());
980 }
981
982 #[test]
983 fn disable_next_line_does_not_suppress_other_lines() {
984 let text =
985 "-- sql-dialect-fmt: disable-next-line SDF001\nSELECT a FROM t;\nSELECT * FROM t;";
986 assert_eq!(lint(text).len(), 1);
987 }
988
989 #[test]
990 fn disable_next_line_suppresses_new_rules_too() {
991 for (text, code) in [
992 (
993 "-- sql-dialect-fmt: disable-next-line SDF004\nDELETE FROM t;",
994 "SDF004",
995 ),
996 (
997 "-- sql-dialect-fmt: disable-next-line SDF005\nUPDATE t SET a = 1;",
998 "SDF005",
999 ),
1000 (
1001 "-- sql-dialect-fmt: disable-next-line SDF006\nSELECT a FROM t1, t2;",
1002 "SDF006",
1003 ),
1004 (
1005 "-- sql-dialect-fmt: disable-next-line SDF007\nSELECT a FROM t ORDER BY 1;",
1006 "SDF007",
1007 ),
1008 ] {
1009 assert!(lint(text).is_empty(), "{code} should be suppressed");
1010 }
1011 }
1012
1013 #[test]
1014 fn codes_round_trip() {
1015 for code in [
1016 LintCode::SelectWildcard,
1017 LintCode::LargeInList,
1018 LintCode::UnsupportedEmbeddedLanguage,
1019 LintCode::DeleteWithoutWhere,
1020 LintCode::UpdateWithoutWhere,
1021 LintCode::CommaJoin,
1022 LintCode::OrderByOrdinal,
1023 ] {
1024 assert_eq!(LintCode::from_code(code.as_str()), Some(code));
1025 }
1026 assert_eq!(LintCode::from_code("SDF999"), None);
1027 }
1028}