Skip to content

Commit 443587a

Browse files
committed
fix(rules): treat a parenthesized JOIN condition as a condition
SQLCLEAN003 read only the direct tokens after ON, so ON (a = b OR c = d) looked empty and was reported. It now reads nested tokens too, while ON (1 = 1) is still reported.
1 parent cec0d38 commit 443587a

3 files changed

Lines changed: 59 additions & 22 deletions

File tree

‎crates/sqlclean/src/sql_lint/_helpers/engine.rs‎

Lines changed: 7 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ use crate::sql_lint::models::{
1313

1414
use crate::sql_lint::_helpers::additional::collect_additional_facts;
1515
use crate::sql_lint::_helpers::inline_relations::collect_inline_query_relation_spans;
16-
use crate::sql_lint::_helpers::join_predicates::cross_join_diagnostics;
16+
use crate::sql_lint::_helpers::join_predicates::{
17+
condition_tokens, cross_join_diagnostics, trivial_on_condition,
18+
};
1719
use crate::sql_lint::_helpers::parsing::parse;
1820
use crate::sql_lint::_helpers::terminal_shape::collect_terminal_shape_facts;
1921

@@ -213,7 +215,6 @@ const INLINE_QUERY_RELATION: LintRuleMetadata = LintRuleMetadata {
213215
message: "Inline query relation obscures data flow",
214216
remediation: "Extract the derived relation into a named top-level CTE; use explicit LATERAL only when the relation must remain correlated.",
215217
};
216-
const TRIVIAL_EQUALITY_TOKEN_COUNT: usize = 3;
217218
const JOIN_EXPRESSION: LintRuleMetadata = LintRuleMetadata {
218219
code: "SQLCLEAN040",
219220
message: "JOIN ON contains a computed expression",
@@ -901,8 +902,10 @@ fn collect_from_and_join_facts(tokens: &[Token], direct: &[usize]) -> (Vec<Span>
901902
.iter()
902903
.any(|&candidate| tokens[candidate].token_type == TokenType::Using);
903904
if !has_using
904-
&& on_position
905-
.is_none_or(|offset| trivial_on_condition(tokens, &condition[offset + 1..]))
905+
&& on_position.is_none_or(|offset| {
906+
let end = direct.get(condition_end).copied().unwrap_or(tokens.len());
907+
trivial_on_condition(tokens, &condition_tokens(tokens, condition[offset], end))
908+
})
906909
{
907910
missing_conditions.push(tokens[index].span);
908911
}
@@ -943,24 +946,6 @@ fn is_conditionless_join(tokens: &[Token], direct: &[usize], join_position: usiz
943946
})
944947
}
945948

946-
fn trivial_on_condition(tokens: &[Token], condition: &[usize]) -> bool {
947-
let significant: Vec<&Token> = condition
948-
.iter()
949-
.map(|&index| &tokens[index])
950-
.filter(|token| !matches!(token.token_type, TokenType::LParen | TokenType::RParen))
951-
.collect();
952-
significant.is_empty()
953-
|| significant.len() == 1 && significant[0].token_type == TokenType::True
954-
|| significant.len() == TRIVIAL_EQUALITY_TOKEN_COUNT
955-
&& significant[1].token_type == TokenType::Eq
956-
&& matches!(
957-
significant[0].token_type,
958-
TokenType::Number | TokenType::String | TokenType::True | TokenType::False
959-
)
960-
&& significant[0].token_type == significant[2].token_type
961-
&& significant[0].text == significant[2].text
962-
}
963-
964949
fn collect_set_operation_facts(tokens: &[Token], depths: &[usize]) -> Vec<Span> {
965950
let mut spans: Vec<Span> = Vec::new();
966951
for (index, token) in tokens.iter().enumerate() {

‎crates/sqlclean/src/sql_lint/_helpers/join_predicates.rs‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,17 @@ use polyglot_sql::tokens::{Span, Token, TokenType};
99
use polyglot_sql::{DialectType, ExpressionWalk};
1010

1111
use crate::constants::SETTING_SNIPPET_INDENT;
12+
use crate::sql_lint::_helpers::engine::{is_comment, is_layout};
1213
use crate::sql_lint::constants::{
1314
CROSS_JOIN_CTE_MARKER, CROSS_JOIN_PATH_PLACEHOLDER, CROSS_JOIN_PREFIX,
1415
CROSS_JOIN_REASON_PLACEHOLDER, CROSS_JOIN_SUFFIX, CROSS_JOIN_UNNAMED_MESSAGE,
1516
};
1617
use crate::sql_lint::models::{CrossJoinRequest, LintDiagnostic, LintRuleMetadata};
1718

1819
const CROSS_JOIN_FIX_REASON: &str = "a keyed relationship requires user intent";
20+
21+
/// Tokens in a constant `ON` condition such as `1 = 1`.
22+
const TRIVIAL_EQUALITY_TOKEN_COUNT: usize = 3;
1923
const FIRST_ROW: &str = "1";
2024
const ROW_GENERATOR_FUNCTIONS: [&str; 16] = [
2125
"unnest",
@@ -692,3 +696,39 @@ fn relation_anchor_candidates(tokens: &[Token], significant: &[usize], cross: Sp
692696
}
693697
candidates
694698
}
699+
700+
/// Every token after `ON` before `end`, including those nested in parentheses, stopping at a parenthesis the condition did not open.
701+
pub(super) fn condition_tokens(tokens: &[Token], on: usize, end: usize) -> Vec<usize> {
702+
let mut open: usize = 0;
703+
let mut found: Vec<usize> = Vec::new();
704+
for (index, token) in tokens.iter().enumerate().take(end).skip(on + 1) {
705+
match token.token_type {
706+
TokenType::LParen => open += 1,
707+
TokenType::RParen if open == 0 => break,
708+
TokenType::RParen => open -= 1,
709+
_ => {}
710+
}
711+
if !is_layout(token) && !is_comment(token) {
712+
found.push(index);
713+
}
714+
}
715+
found
716+
}
717+
718+
pub(super) fn trivial_on_condition(tokens: &[Token], condition: &[usize]) -> bool {
719+
let significant: Vec<&Token> = condition
720+
.iter()
721+
.map(|&index| &tokens[index])
722+
.filter(|token| !matches!(token.token_type, TokenType::LParen | TokenType::RParen))
723+
.collect();
724+
significant.is_empty()
725+
|| significant.len() == 1 && significant[0].token_type == TokenType::True
726+
|| significant.len() == TRIVIAL_EQUALITY_TOKEN_COUNT
727+
&& significant[1].token_type == TokenType::Eq
728+
&& matches!(
729+
significant[0].token_type,
730+
TokenType::Number | TokenType::String | TokenType::True | TokenType::False
731+
)
732+
&& significant[0].token_type == significant[2].token_type
733+
&& significant[0].text == significant[2].text
734+
}

‎crates/sqlclean/src/sql_lint/tests/test_sql_lint.rs‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,18 @@ fn given_sql_cases_when_linting_then_diagnostics_match() -> Result<(), String> {
7878
expected_codes: &["SQLCLEAN003"],
7979
expected_anchors: &[("SQLCLEAN003", "JOIN")],
8080
},
81+
test_types::LintTestCase {
82+
description: "parenthesized join condition is a condition",
83+
sql: "SELECT a.id FROM a JOIN b ON (a.id = b.id OR a.status = 'new')",
84+
expected_codes: &[],
85+
expected_anchors: &[],
86+
},
87+
test_types::LintTestCase {
88+
description: "parenthesized constant join condition is no condition",
89+
sql: "SELECT a.id FROM a JOIN b ON (1 = 1)",
90+
expected_codes: &["SQLCLEAN003"],
91+
expected_anchors: &[("SQLCLEAN003", "JOIN")],
92+
},
8193
test_types::LintTestCase {
8294
description: "unordered limit",
8395
sql: "SELECT id FROM items LIMIT 1",

0 commit comments

Comments
 (0)