1use crate::linter::config::LintConfig;
6use crate::linter::rule::{LintContext, LintRule};
7use crate::types::{issue_codes, Dialect, Issue, IssueAutofixApplicability, IssuePatchEdit, Span};
8use sqlparser::ast::{
9 Expr, FromTable, Ident, Query, SetExpr, Statement, TableFactor, TableWithJoins,
10 UpdateTableFromKind,
11};
12use sqlparser::tokenizer::{Token, TokenWithSpan, Tokenizer, Whitespace};
13
14#[derive(Clone, Copy, Debug, Eq, PartialEq)]
15enum AliasingPreference {
16 Explicit,
17 Implicit,
18}
19
20impl AliasingPreference {
21 fn from_config(config: &LintConfig, rule_code: &str) -> Self {
22 match config
23 .rule_option_str(rule_code, "aliasing")
24 .unwrap_or("explicit")
25 .to_ascii_lowercase()
26 .as_str()
27 {
28 "implicit" => Self::Implicit,
29 _ => Self::Explicit,
30 }
31 }
32
33 fn message(self) -> &'static str {
34 match self {
35 Self::Explicit => "Use explicit AS when aliasing tables.",
36 Self::Implicit => "Use implicit aliasing when aliasing tables (omit AS).",
37 }
38 }
39
40 fn violation(self, explicit_as: bool) -> bool {
41 match self {
42 Self::Explicit => !explicit_as,
43 Self::Implicit => explicit_as,
44 }
45 }
46}
47
48pub struct AliasingTableStyle {
49 aliasing: AliasingPreference,
50}
51
52impl AliasingTableStyle {
53 pub fn from_config(config: &LintConfig) -> Self {
54 Self {
55 aliasing: AliasingPreference::from_config(config, issue_codes::LINT_AL_001),
56 }
57 }
58}
59
60impl Default for AliasingTableStyle {
61 fn default() -> Self {
62 Self {
63 aliasing: AliasingPreference::Explicit,
64 }
65 }
66}
67
68impl LintRule for AliasingTableStyle {
69 fn code(&self) -> &'static str {
70 issue_codes::LINT_AL_001
71 }
72
73 fn name(&self) -> &'static str {
74 "Table alias style"
75 }
76
77 fn description(&self) -> &'static str {
78 "Implicit/explicit aliasing of table."
79 }
80
81 fn check(&self, statement: &Statement, ctx: &LintContext) -> Vec<Issue> {
82 let mut issues = Vec::new();
83 let tokens =
84 tokenized_for_context(ctx).or_else(|| tokenized(ctx.statement_sql(), ctx.dialect()));
85
86 collect_table_aliases_in_statement(statement, &mut |alias| {
87 let Some(occurrence) = alias_occurrence_in_statement(alias, ctx, tokens.as_deref())
88 else {
89 return;
90 };
91
92 if !self.aliasing.violation(occurrence.explicit_as) {
93 return;
94 }
95
96 let mut issue = Issue::warning(issue_codes::LINT_AL_001, self.aliasing.message())
97 .with_statement(ctx.statement_index)
98 .with_span(ctx.span_from_statement_offset(occurrence.start, occurrence.end));
99 if let Some(edits) = autofix_edits_for_occurrence(occurrence, self.aliasing) {
100 issue = issue.with_autofix_edits(IssueAutofixApplicability::Safe, edits);
101 }
102
103 issues.push(issue);
104 });
105
106 issues
107 }
108}
109
110#[derive(Clone, Copy)]
111struct AliasOccurrence {
112 start: usize,
113 end: usize,
114 explicit_as: bool,
115 as_span: Option<Span>,
116 has_leading_whitespace: bool,
118}
119
120fn autofix_edits_for_occurrence(
121 occurrence: AliasOccurrence,
122 aliasing: AliasingPreference,
123) -> Option<Vec<IssuePatchEdit>> {
124 match aliasing {
125 AliasingPreference::Explicit if !occurrence.explicit_as => {
126 let insert = Span::new(occurrence.start, occurrence.start);
127 let replacement = if occurrence.has_leading_whitespace {
128 "AS "
129 } else {
130 " AS "
131 };
132 Some(vec![IssuePatchEdit::new(insert, replacement)])
133 }
134 AliasingPreference::Implicit if occurrence.explicit_as => {
135 let as_span = occurrence.as_span?;
136 let delete_end = occurrence.start;
139 Some(vec![IssuePatchEdit::new(
140 Span::new(as_span.start, delete_end),
141 " ",
142 )])
143 }
144 _ => None,
145 }
146}
147
148fn alias_occurrence_in_statement(
149 alias: &Ident,
150 ctx: &LintContext,
151 tokens: Option<&[LocatedToken]>,
152) -> Option<AliasOccurrence> {
153 let tokens = tokens?;
154
155 let abs_start = line_col_to_offset(
156 ctx.sql,
157 alias.span.start.line as usize,
158 alias.span.start.column as usize,
159 )?;
160 let abs_end = line_col_to_offset(
161 ctx.sql,
162 alias.span.end.line as usize,
163 alias.span.end.column as usize,
164 )?;
165
166 if abs_start < ctx.statement_range.start || abs_end > ctx.statement_range.end {
167 return None;
168 }
169
170 let rel_start = abs_start - ctx.statement_range.start;
171 let rel_end = abs_end - ctx.statement_range.start;
172 let (explicit_as, as_span) = explicit_as_before_alias_tokens(tokens, rel_start)?;
173 let has_leading_whitespace = has_whitespace_before(tokens, rel_start);
174 Some(AliasOccurrence {
175 start: rel_start,
176 end: rel_end,
177 explicit_as,
178 as_span,
179 has_leading_whitespace,
180 })
181}
182
183fn collect_table_aliases_in_statement<F: FnMut(&Ident)>(statement: &Statement, visitor: &mut F) {
184 match statement {
185 Statement::Query(query) => collect_table_aliases_in_query(query, visitor),
186 Statement::Insert(insert) => {
187 if let Some(source) = &insert.source {
188 collect_table_aliases_in_query(source, visitor);
189 }
190 }
191 Statement::CreateView { query, .. } => collect_table_aliases_in_query(query, visitor),
192 Statement::CreateTable(create) => {
193 if let Some(query) = &create.query {
194 collect_table_aliases_in_query(query, visitor);
195 }
196 }
197 Statement::Update {
198 table,
199 from,
200 selection,
201 ..
202 } => {
203 for join in &table.joins {
207 collect_table_aliases_in_table_factor(&join.relation, visitor);
208 }
209 if let Some(from) = from {
210 match from {
211 UpdateTableFromKind::BeforeSet(tables)
212 | UpdateTableFromKind::AfterSet(tables) => {
213 for t in tables {
214 collect_table_aliases_in_table_with_joins(t, visitor);
215 }
216 }
217 }
218 }
219 if let Some(selection) = selection {
220 collect_table_aliases_in_expr(selection, visitor);
221 }
222 }
223 Statement::Delete(delete) => {
224 match &delete.from {
225 FromTable::WithFromKeyword(tables) | FromTable::WithoutKeyword(tables) => {
226 for t in tables {
227 collect_table_aliases_in_table_with_joins(t, visitor);
228 }
229 }
230 }
231 if let Some(using) = &delete.using {
232 for t in using {
233 collect_table_aliases_in_table_with_joins(t, visitor);
234 }
235 }
236 if let Some(selection) = &delete.selection {
237 collect_table_aliases_in_expr(selection, visitor);
238 }
239 }
240 Statement::Merge { table, source, .. } => {
241 collect_table_aliases_in_table_factor(table, visitor);
242 collect_table_aliases_in_table_factor(source, visitor);
243 }
244 _ => {}
245 }
246}
247
248fn collect_table_aliases_in_query<F: FnMut(&Ident)>(query: &Query, visitor: &mut F) {
249 if let Some(with) = &query.with {
250 for cte in &with.cte_tables {
251 collect_table_aliases_in_query(&cte.query, visitor);
252 }
253 }
254
255 collect_table_aliases_in_set_expr(&query.body, visitor);
256}
257
258fn collect_table_aliases_in_set_expr<F: FnMut(&Ident)>(set_expr: &SetExpr, visitor: &mut F) {
259 match set_expr {
260 SetExpr::Select(select) => {
261 for table in &select.from {
262 collect_table_aliases_in_table_with_joins(table, visitor);
263 for join in &table.joins {
265 if let Some(expr) = join_constraint_expr(&join.join_operator) {
266 collect_table_aliases_in_expr(expr, visitor);
267 }
268 }
269 }
270 if let Some(selection) = &select.selection {
271 collect_table_aliases_in_expr(selection, visitor);
272 }
273 if let Some(having) = &select.having {
274 collect_table_aliases_in_expr(having, visitor);
275 }
276 if let Some(qualify) = &select.qualify {
277 collect_table_aliases_in_expr(qualify, visitor);
278 }
279 for item in &select.projection {
281 match item {
282 sqlparser::ast::SelectItem::UnnamedExpr(expr)
283 | sqlparser::ast::SelectItem::ExprWithAlias { expr, .. } => {
284 collect_table_aliases_in_expr(expr, visitor);
285 }
286 _ => {}
287 }
288 }
289 }
290 SetExpr::Query(query) => collect_table_aliases_in_query(query, visitor),
291 SetExpr::SetOperation { left, right, .. } => {
292 collect_table_aliases_in_set_expr(left, visitor);
293 collect_table_aliases_in_set_expr(right, visitor);
294 }
295 SetExpr::Insert(statement)
296 | SetExpr::Update(statement)
297 | SetExpr::Delete(statement)
298 | SetExpr::Merge(statement) => collect_table_aliases_in_statement(statement, visitor),
299 _ => {}
300 }
301}
302
303fn collect_table_aliases_in_table_with_joins<F: FnMut(&Ident)>(
304 table_with_joins: &TableWithJoins,
305 visitor: &mut F,
306) {
307 collect_table_aliases_in_table_factor(&table_with_joins.relation, visitor);
308 for join in &table_with_joins.joins {
309 collect_table_aliases_in_table_factor(&join.relation, visitor);
310 }
311}
312
313fn collect_table_aliases_in_table_factor<F: FnMut(&Ident)>(
314 table_factor: &TableFactor,
315 visitor: &mut F,
316) {
317 if let Some(alias) = table_factor_alias_ident(table_factor) {
318 visitor(alias);
319 }
320
321 match table_factor {
322 TableFactor::Derived { subquery, .. } => collect_table_aliases_in_query(subquery, visitor),
323 TableFactor::NestedJoin {
324 table_with_joins, ..
325 } => collect_table_aliases_in_table_with_joins(table_with_joins, visitor),
326 TableFactor::Pivot { table, .. }
327 | TableFactor::Unpivot { table, .. }
328 | TableFactor::MatchRecognize { table, .. } => {
329 collect_table_aliases_in_table_factor(table, visitor)
330 }
331 _ => {}
332 }
333}
334
335fn collect_table_aliases_in_expr<F: FnMut(&Ident)>(expr: &Expr, visitor: &mut F) {
337 match expr {
338 Expr::Subquery(query)
339 | Expr::Exists {
340 subquery: query, ..
341 } => {
342 collect_table_aliases_in_query(query, visitor);
343 }
344 Expr::InSubquery {
345 expr: inner,
346 subquery,
347 ..
348 } => {
349 collect_table_aliases_in_expr(inner, visitor);
350 collect_table_aliases_in_query(subquery, visitor);
351 }
352 Expr::BinaryOp { left, right, .. } => {
353 collect_table_aliases_in_expr(left, visitor);
354 collect_table_aliases_in_expr(right, visitor);
355 }
356 Expr::UnaryOp { expr: inner, .. }
357 | Expr::Nested(inner)
358 | Expr::Cast { expr: inner, .. } => {
359 collect_table_aliases_in_expr(inner, visitor);
360 }
361 Expr::Case {
362 operand,
363 conditions,
364 else_result,
365 ..
366 } => {
367 if let Some(op) = operand {
368 collect_table_aliases_in_expr(op, visitor);
369 }
370 for cw in conditions {
371 collect_table_aliases_in_expr(&cw.condition, visitor);
372 collect_table_aliases_in_expr(&cw.result, visitor);
373 }
374 if let Some(el) = else_result {
375 collect_table_aliases_in_expr(el, visitor);
376 }
377 }
378 Expr::Function(func) => {
379 if let sqlparser::ast::FunctionArguments::List(arg_list) = &func.args {
380 for arg in &arg_list.args {
381 match arg {
382 sqlparser::ast::FunctionArg::Unnamed(
383 sqlparser::ast::FunctionArgExpr::Expr(e),
384 )
385 | sqlparser::ast::FunctionArg::Named {
386 arg: sqlparser::ast::FunctionArgExpr::Expr(e),
387 ..
388 } => collect_table_aliases_in_expr(e, visitor),
389 _ => {}
390 }
391 }
392 } else if let sqlparser::ast::FunctionArguments::Subquery(query) = &func.args {
393 collect_table_aliases_in_query(query, visitor);
394 }
395 }
396 Expr::Between {
397 expr: inner,
398 low,
399 high,
400 ..
401 } => {
402 collect_table_aliases_in_expr(inner, visitor);
403 collect_table_aliases_in_expr(low, visitor);
404 collect_table_aliases_in_expr(high, visitor);
405 }
406 Expr::InList {
407 expr: inner, list, ..
408 } => {
409 collect_table_aliases_in_expr(inner, visitor);
410 for item in list {
411 collect_table_aliases_in_expr(item, visitor);
412 }
413 }
414 Expr::IsNull(inner) | Expr::IsNotNull(inner) => {
415 collect_table_aliases_in_expr(inner, visitor);
416 }
417 _ => {}
418 }
419}
420
421fn join_constraint_expr(op: &sqlparser::ast::JoinOperator) -> Option<&Expr> {
422 use sqlparser::ast::{JoinConstraint, JoinOperator};
423 let constraint = match op {
424 JoinOperator::Join(c)
425 | JoinOperator::Inner(c)
426 | JoinOperator::Left(c)
427 | JoinOperator::LeftOuter(c)
428 | JoinOperator::Right(c)
429 | JoinOperator::RightOuter(c)
430 | JoinOperator::FullOuter(c)
431 | JoinOperator::CrossJoin(c)
432 | JoinOperator::Semi(c)
433 | JoinOperator::LeftSemi(c)
434 | JoinOperator::RightSemi(c)
435 | JoinOperator::Anti(c)
436 | JoinOperator::LeftAnti(c)
437 | JoinOperator::RightAnti(c)
438 | JoinOperator::StraightJoin(c) => c,
439 JoinOperator::AsOf { constraint, .. } => constraint,
440 JoinOperator::CrossApply | JoinOperator::OuterApply => return None,
441 };
442 if let JoinConstraint::On(expr) = constraint {
443 Some(expr)
444 } else {
445 None
446 }
447}
448
449fn table_factor_alias_ident(table_factor: &TableFactor) -> Option<&Ident> {
450 let alias = match table_factor {
451 TableFactor::Table { alias, .. }
452 | TableFactor::Derived { alias, .. }
453 | TableFactor::TableFunction { alias, .. }
454 | TableFactor::Function { alias, .. }
455 | TableFactor::UNNEST { alias, .. }
456 | TableFactor::JsonTable { alias, .. }
457 | TableFactor::OpenJsonTable { alias, .. }
458 | TableFactor::NestedJoin { alias, .. }
459 | TableFactor::Pivot { alias, .. }
460 | TableFactor::Unpivot { alias, .. }
461 | TableFactor::MatchRecognize { alias, .. }
462 | TableFactor::XmlTable { alias, .. }
463 | TableFactor::SemanticView { alias, .. } => alias.as_ref(),
464 }?;
465
466 Some(&alias.name)
467}
468
469fn explicit_as_before_alias_tokens(
470 tokens: &[LocatedToken],
471 alias_start: usize,
472) -> Option<(bool, Option<Span>)> {
473 let token = tokens
474 .iter()
475 .rev()
476 .find(|token| token.end <= alias_start && !is_trivia_token(&token.token))?;
477 if is_as_token(&token.token) {
478 let leading_ws_start = tokens
480 .iter()
481 .rev()
482 .find(|t| t.end <= token.start && !is_trivia_token(&t.token))
483 .map(|t| t.end)
484 .unwrap_or(token.start);
485 Some((true, Some(Span::new(leading_ws_start, token.end))))
486 } else {
487 Some((false, None))
488 }
489}
490
491fn has_whitespace_before(tokens: &[LocatedToken], pos: usize) -> bool {
493 tokens
494 .iter()
495 .rev()
496 .find(|t| t.end <= pos)
497 .is_some_and(|t| is_trivia_token(&t.token))
498}
499
500fn is_as_token(token: &Token) -> bool {
501 match token {
502 Token::Word(word) => word.value.eq_ignore_ascii_case("AS"),
503 _ => false,
504 }
505}
506
507#[derive(Clone)]
508struct LocatedToken {
509 token: Token,
510 start: usize,
511 end: usize,
512}
513
514fn tokenized(sql: &str, dialect: Dialect) -> Option<Vec<LocatedToken>> {
515 let dialect = dialect.to_sqlparser_dialect();
516 let mut tokenizer = Tokenizer::new(dialect.as_ref(), sql);
517 let tokens = tokenizer.tokenize_with_location().ok()?;
518
519 let mut out = Vec::with_capacity(tokens.len());
520 for token in tokens {
521 let (start, end) = token_with_span_offsets(sql, &token)?;
522 out.push(LocatedToken {
523 token: token.token,
524 start,
525 end,
526 });
527 }
528 Some(out)
529}
530
531fn tokenized_for_context(ctx: &LintContext) -> Option<Vec<LocatedToken>> {
532 let statement_start = ctx.statement_range.start;
533 ctx.with_document_tokens(|tokens| {
534 if tokens.is_empty() {
535 return None;
536 }
537
538 Some(
539 tokens
540 .iter()
541 .filter_map(|token| {
542 let (start, end) = token_with_span_offsets(ctx.sql, token)?;
543 if start < ctx.statement_range.start || end > ctx.statement_range.end {
544 return None;
545 }
546 Some(LocatedToken {
547 token: token.token.clone(),
548 start: start - statement_start,
549 end: end - statement_start,
550 })
551 })
552 .collect::<Vec<_>>(),
553 )
554 })
555}
556
557fn token_with_span_offsets(sql: &str, token: &TokenWithSpan) -> Option<(usize, usize)> {
558 let start = line_col_to_offset(
559 sql,
560 token.span.start.line as usize,
561 token.span.start.column as usize,
562 )?;
563 let end = line_col_to_offset(
564 sql,
565 token.span.end.line as usize,
566 token.span.end.column as usize,
567 )?;
568 Some((start, end))
569}
570
571fn is_trivia_token(token: &Token) -> bool {
572 matches!(
573 token,
574 Token::Whitespace(Whitespace::Space | Whitespace::Tab | Whitespace::Newline)
575 | Token::Whitespace(Whitespace::SingleLineComment { .. })
576 | Token::Whitespace(Whitespace::MultiLineComment(_))
577 )
578}
579
580fn line_col_to_offset(sql: &str, line: usize, column: usize) -> Option<usize> {
581 if line == 0 || column == 0 {
582 return None;
583 }
584
585 let mut current_line = 1usize;
586 let mut current_col = 1usize;
587
588 for (offset, ch) in sql.char_indices() {
589 if current_line == line && current_col == column {
590 return Some(offset);
591 }
592
593 if ch == '\n' {
594 current_line += 1;
595 current_col = 1;
596 } else {
597 current_col += 1;
598 }
599 }
600
601 if current_line == line && current_col == column {
602 return Some(sql.len());
603 }
604
605 None
606}
607
608#[cfg(test)]
609mod tests {
610 use super::*;
611 use crate::{
612 parser::{parse_sql, parse_sql_with_dialect},
613 types::IssueAutofixApplicability,
614 Dialect,
615 };
616
617 fn run_with_rule(sql: &str, rule: AliasingTableStyle) -> Vec<Issue> {
618 let stmts = parse_sql(sql).expect("parse");
619 stmts
620 .iter()
621 .enumerate()
622 .flat_map(|(index, stmt)| {
623 rule.check(
624 stmt,
625 &LintContext {
626 sql,
627 statement_range: 0..sql.len(),
628 statement_index: index,
629 },
630 )
631 })
632 .collect()
633 }
634
635 fn run(sql: &str) -> Vec<Issue> {
636 run_with_rule(sql, AliasingTableStyle::default())
637 }
638
639 #[test]
640 fn flags_implicit_table_aliases() {
641 let issues = run("select * from users u join orders o on u.id = o.user_id");
642 assert_eq!(issues.len(), 2);
643 assert!(issues.iter().all(|i| i.code == issue_codes::LINT_AL_001));
644 }
645
646 #[test]
647 fn allows_explicit_as_table_aliases() {
648 let issues = run("select * from users as u join orders as o on u.id = o.user_id");
649 assert!(issues.is_empty());
650 }
651
652 #[test]
653 fn flags_explicit_aliases_when_implicit_policy_requested() {
654 let config = LintConfig {
655 enabled: true,
656 disabled_rules: vec![],
657 rule_configs: std::collections::BTreeMap::from([(
658 "LINT_AL_001".to_string(),
659 serde_json::json!({"aliasing": "implicit"}),
660 )]),
661 };
662 let issues = run_with_rule(
663 "select * from users as u join orders as o on u.id = o.user_id",
664 AliasingTableStyle::from_config(&config),
665 );
666 assert_eq!(issues.len(), 2);
667 }
668
669 #[test]
670 fn flags_implicit_derived_table_alias() {
671 let issues = run("select * from (select 1) d");
672 assert_eq!(issues.len(), 1);
673 assert_eq!(issues[0].code, issue_codes::LINT_AL_001);
674 }
675
676 #[test]
677 fn flags_implicit_merge_aliases_in_bigquery() {
678 let sql = "MERGE dataset.inventory t USING dataset.newarrivals s ON t.product = s.product WHEN MATCHED THEN UPDATE SET quantity = t.quantity + s.quantity";
679 let statements = parse_sql_with_dialect(sql, Dialect::Bigquery).expect("parse");
680 let issues = statements
681 .iter()
682 .enumerate()
683 .flat_map(|(index, stmt)| {
684 AliasingTableStyle::default().check(
685 stmt,
686 &LintContext {
687 sql,
688 statement_range: 0..sql.len(),
689 statement_index: index,
690 },
691 )
692 })
693 .collect::<Vec<_>>();
694 assert_eq!(issues.len(), 2);
695 assert!(issues.iter().all(|i| i.code == issue_codes::LINT_AL_001));
696 }
697
698 #[test]
699 fn explicit_mode_emits_safe_insert_as_autofix_patch() {
700 let sql = "select * from users u";
701 let issues = run(sql);
702 assert_eq!(issues.len(), 1);
703
704 let autofix = issues[0]
705 .autofix
706 .as_ref()
707 .expect("expected AL001 core autofix metadata in explicit mode");
708 assert_eq!(autofix.applicability, IssueAutofixApplicability::Safe);
709 assert_eq!(autofix.edits.len(), 1);
710 assert_eq!(autofix.edits[0].replacement, "AS ");
711 assert_eq!(autofix.edits[0].span.start, autofix.edits[0].span.end);
712 }
713
714 #[test]
715 fn implicit_mode_emits_safe_remove_as_autofix_patch() {
716 let config = LintConfig {
717 enabled: true,
718 disabled_rules: vec![],
719 rule_configs: std::collections::BTreeMap::from([(
720 "LINT_AL_001".to_string(),
721 serde_json::json!({"aliasing": "implicit"}),
722 )]),
723 };
724 let rule = AliasingTableStyle::from_config(&config);
725 let sql = "select * from users as u";
726 let issues = run_with_rule(sql, rule);
727 assert_eq!(issues.len(), 1);
728
729 let autofix = issues[0]
730 .autofix
731 .as_ref()
732 .expect("expected AL001 core autofix metadata in implicit mode");
733 assert_eq!(autofix.applicability, IssueAutofixApplicability::Safe);
734 assert_eq!(autofix.edits.len(), 1);
735 assert_eq!(autofix.edits[0].replacement, " ");
736 assert_eq!(
738 &sql[autofix.edits[0].span.start..autofix.edits[0].span.end],
739 " as "
740 );
741 }
742
743 #[test]
744 fn flags_implicit_aliases_in_update_where_exists_subquery() {
745 let sql = "UPDATE t SET x = 1 WHERE EXISTS (SELECT 1 FROM users u)";
746 let issues = run(sql);
747 assert_eq!(issues.len(), 1);
749 assert_eq!(issues[0].code, issue_codes::LINT_AL_001);
750 }
751
752 #[test]
753 fn skips_update_target_table_alias() {
754 let sql = "UPDATE users u SET u.x = 1";
756 let issues = run(sql);
757 assert!(issues.is_empty());
758 }
759
760 #[test]
761 fn flags_implicit_aliases_in_delete_where_exists() {
762 let sql =
763 "DELETE FROM users u WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id)";
764 let issues = run(sql);
765 assert_eq!(issues.len(), 2);
767 }
768}