Skip to content

Commit 4681c3f

Browse files
committed
wip
1 parent c32338c commit 4681c3f

File tree

4 files changed

+353
-3
lines changed

4 files changed

+353
-3
lines changed

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/uroborosql-lint/src/linter.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ use crate::{
33
diagnostic::{Diagnostic, Severity},
44
rule::Rule,
55
rules::{
6-
MissingTwoWaySample, NoDistinct, NoNotIn, NoUnionDistinct, NoWildcardProjection,
7-
TooLargeInList,
6+
MissingTwoWaySample, NoDistinct, NoFunctionInJoinOrWhere, NoNotIn, NoUnionDistinct,
7+
NoWildcardProjection, TooLargeInList,
88
},
99
tree::collect_preorder,
1010
};
@@ -110,6 +110,7 @@ fn default_rules() -> Vec<Box<dyn Rule>> {
110110
Box::new(NoDistinct),
111111
Box::new(NoNotIn),
112112
Box::new(NoUnionDistinct),
113+
Box::new(NoFunctionInJoinOrWhere),
113114
Box::new(NoWildcardProjection),
114115
Box::new(MissingTwoWaySample),
115116
Box::new(TooLargeInList),

crates/uroborosql-lint/src/rules.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
mod missing_two_way_sample;
22
mod no_distinct;
3+
mod no_function_in_join_or_where;
34
mod no_not_in;
45
mod no_union_distinct;
56
mod no_wildcard_projection;
67
mod too_large_in_list;
78

89
pub use missing_two_way_sample::MissingTwoWaySample;
910
pub use no_distinct::NoDistinct;
11+
pub use no_function_in_join_or_where::NoFunctionInJoinOrWhere;
1012
pub use no_not_in::NoNotIn;
1113
pub use no_union_distinct::NoUnionDistinct;
1214
pub use no_wildcard_projection::NoWildcardProjection;
Lines changed: 347 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,347 @@
1+
use crate::{
2+
context::LintContext,
3+
diagnostic::{Diagnostic, Severity},
4+
rule::Rule,
5+
};
6+
use postgresql_cst_parser::{
7+
syntax_kind::SyntaxKind,
8+
tree_sitter::{Node, Range},
9+
};
10+
11+
/// Detects functions use in JOIN or WHERE conditions.
12+
/// source: https://future-architect.github.io/coding-standards/documents/forSQL/SQL%E3%82%B3%E3%83%BC%E3%83%87%E3%82%A3%E3%83%B3%E3%82%B0%E8%A6%8F%E7%B4%84%EF%BC%88PostgreSQL%EF%BC%89.html#:~:text=1-,%E3%82%A4%E3%83%B3%E3%83%87%E3%83%83%E3%82%AF%E3%82%B9%E3%82%AB%E3%83%A9%E3%83%A0%E3%81%AB%E9%96%A2%E6%95%B0,-%E3%82%92%E9%80%9A%E3%81%97%E3%81%9F
13+
pub struct NoFunctionInJoinOrWhere;
14+
15+
impl Rule for NoFunctionInJoinOrWhere {
16+
fn name(&self) -> &'static str {
17+
"no-function-in-join-or-where"
18+
}
19+
20+
fn default_severity(&self) -> Severity {
21+
Severity::Warning
22+
}
23+
24+
fn target_kinds(&self) -> &'static [SyntaxKind] {
25+
&[SyntaxKind::join_qual, SyntaxKind::where_clause]
26+
}
27+
28+
fn run_on_node<'tree>(&self, node: &Node<'tree>, ctx: &mut LintContext, severity: Severity) {
29+
assert!(matches!(
30+
node.kind(),
31+
SyntaxKind::join_qual | SyntaxKind::where_clause
32+
));
33+
34+
let Some(top_expr) = find_top_expr(node) else {
35+
return;
36+
};
37+
38+
let ranges = detect_column_function_calls(&top_expr);
39+
40+
for range in ranges {
41+
let diagnostic = Diagnostic::new(
42+
self.name(),
43+
severity,
44+
"Functions in JOIN or WHERE conditions can prevent index usage; rewrite without wrapping the column.",
45+
&range,
46+
);
47+
ctx.report(diagnostic);
48+
}
49+
}
50+
}
51+
52+
/// Finds the top `a_expr` in a JOIN or WHERE clause.
53+
fn find_top_expr<'a>(join_qual_or_where_clause: &'a Node<'a>) -> Option<Node<'a>> {
54+
// join_qual:
55+
// - ON a_expr
56+
// - USING '(' name_list ')' opt_alias_clause_for_joiln_using
57+
//
58+
// where_clause:
59+
// - a_expr
60+
61+
let last_child = join_qual_or_where_clause
62+
.last_child()
63+
.expect("join_qual or where_clause must have a last child.");
64+
65+
if last_child.kind() == SyntaxKind::a_expr {
66+
Some(last_child)
67+
} else {
68+
None
69+
}
70+
}
71+
72+
// a_expr の子孫に func_expr_windowless が出現することはない
73+
//
74+
// その他関数系ノード
75+
// - func_expr
76+
// - func_application within_group_clause filter_clause
77+
// - json_aggregate_func filter_clause over_clause
78+
// - func_expr_common_subexpr
79+
//
80+
// - func_application
81+
// - カラムが出現しうる箇所は子供のうち func_arg_list か func_arg_expr を見れば良さそう
82+
//
83+
// - json_aggregate_func
84+
// - JSON_ARRAY_AGG '(' json_value_expr_list json_array_constructor_null_clause_opt json_returning_clause_opt
85+
// - json_value_expr_list
86+
// - json_value_expr
87+
// - a_expr json_format_clause_opt
88+
// - JSON_OBJECT_AGG '(' json_name_and_value json_object_constructor_null_clause_opt json_key_uniqueness_constraint_opt json_returning_clause_opt
89+
// - json_name_and_value
90+
// - c_expr VALUE_P json_value_expr
91+
// - a_expr ':' json_value_expr
92+
//
93+
// - func_expr_common_subexpr
94+
//
95+
// a_expr の子孫で func_expr が現れるまでの最短ルート
96+
// a_expr
97+
// - c_expr
98+
// - func_expr
99+
100+
const FUNCTION_KINDS: &[SyntaxKind] = &[
101+
SyntaxKind::func_application,
102+
SyntaxKind::func_expr_common_subexpr,
103+
SyntaxKind::json_aggregate_func,
104+
];
105+
106+
// SELECT サブクエリ以下は JOIN / WHERE の外側なので探索対象外。
107+
const SUBQUERY_KINDS: &[SyntaxKind] =
108+
&[SyntaxKind::select_with_parens, SyntaxKind::select_no_parens];
109+
110+
fn detect_column_function_calls(a_expr: &Node<'_>) -> Vec<Range> {
111+
let mut ranges = Vec::new();
112+
let mut stack = vec![a_expr.clone()];
113+
114+
while let Some(current) = stack.pop() {
115+
// 対象となる関数ノードを見つけたら、直下に列参照があるかを判定する。
116+
// ネストしている場合は「列に最も近い(内側の)関数だけ」を診断対象にする。
117+
if FUNCTION_KINDS.contains(&current.kind())
118+
&& contains_columnref_excluding_nested_functions(&current)
119+
{
120+
ranges.push(current.range());
121+
}
122+
push_child_nodes(&mut stack, &current);
123+
}
124+
125+
ranges
126+
}
127+
128+
fn contains_columnref_excluding_nested_functions(node: &Node<'_>) -> bool {
129+
let mut stack = Vec::new();
130+
push_child_nodes(&mut stack, node);
131+
132+
while let Some(current) = stack.pop() {
133+
if current.kind() == SyntaxKind::columnref {
134+
return true;
135+
}
136+
// 内側に別の関数が現れたら、そちらで改めて判定するためここでは追わない。
137+
if FUNCTION_KINDS.contains(&current.kind()) {
138+
continue;
139+
}
140+
push_child_nodes(&mut stack, &current);
141+
}
142+
143+
false
144+
}
145+
146+
fn push_child_nodes<'tree>(stack: &mut Vec<Node<'tree>>, node: &Node<'tree>) {
147+
if SUBQUERY_KINDS.contains(&node.kind()) {
148+
return;
149+
}
150+
151+
for child in node.children() {
152+
if child.child_count() == 0 {
153+
continue;
154+
}
155+
stack.push(child);
156+
}
157+
}
158+
159+
#[cfg(test)]
160+
mod tests {
161+
use super::*;
162+
use crate::{linter::tests::run_with_rules, Diagnostic, SqlSpan};
163+
164+
fn run(sql: &str) -> Vec<Diagnostic> {
165+
run_with_rules(sql, vec![Box::new(NoFunctionInJoinOrWhere)])
166+
}
167+
168+
mod where_clause {
169+
use super::*;
170+
171+
#[test]
172+
fn allows_plain_column_comparisons() {
173+
let sql = "SELECT * FROM users WHERE users.id = 1;";
174+
let diagnostics = run(sql);
175+
176+
assert!(diagnostics.is_empty(),);
177+
}
178+
179+
#[test]
180+
fn allows_function_with_constant() {
181+
let sql =
182+
"SELECT * FROM users WHERE users.created_at >= to_date('20160101', 'YYYYMMDD');";
183+
let diagnostics = run(sql);
184+
185+
assert!(diagnostics.is_empty());
186+
}
187+
188+
#[test]
189+
fn detects_function_in_where_clause() {
190+
let sql = "SELECT * FROM users WHERE lower(users.name) = 'foo';";
191+
let diagnostics = run(sql);
192+
193+
assert!(diagnostics
194+
.iter()
195+
.any(|diag| diag.rule_id == "no-function-in-join-or-where"),);
196+
197+
assert_eq!(diagnostics.len(), 1);
198+
199+
let SqlSpan { start, end } = diagnostics[0].span;
200+
assert_eq!(&sql[start.byte..end.byte], "lower(users.name)");
201+
}
202+
203+
#[test]
204+
fn detects_coalesce_usage() {
205+
let sql = "SELECT * FROM users WHERE coalesce(users.deleted_at, users.updated_at) IS NOT NULL;";
206+
let diagnostics = run(sql);
207+
208+
assert_eq!(diagnostics.len(), 1);
209+
210+
let SqlSpan { start, end } = diagnostics[0].span;
211+
assert_eq!(
212+
&sql[start.byte..end.byte],
213+
"coalesce(users.deleted_at, users.updated_at)"
214+
);
215+
}
216+
217+
#[test]
218+
fn detects_only_innermost_function_in_nested_chain() {
219+
let sql = "SELECT * FROM users WHERE lower(trim(users.email)) = 'foo';";
220+
let diagnostics = run(sql);
221+
222+
assert_eq!(
223+
diagnostics.len(),
224+
1,
225+
"only the innermost function wrapping the column should be flagged"
226+
);
227+
228+
let SqlSpan { start, end } = diagnostics[0].span;
229+
assert_eq!(&sql[start.byte..end.byte], "trim(users.email)");
230+
}
231+
232+
#[test]
233+
fn allows_function_on_other_branch_without_column_reference() {
234+
let sql =
235+
"SELECT * FROM users u JOIN vendors v ON u.id = v.user_id WHERE (u.name = v.name) OR 'const' = lower('CONST');";
236+
let diagnostics = run(sql);
237+
238+
assert!(
239+
diagnostics.is_empty(),
240+
"functions that do not reference columns should be allowed even when other branches compare columns"
241+
);
242+
}
243+
244+
#[test]
245+
fn detects_function_on_both_sides() {
246+
let sql = "SELECT * FROM users u1 JOIN users u2 ON trim(u1.email) = trim(u2.email);";
247+
let diagnostics = run(sql);
248+
249+
assert!(diagnostics
250+
.iter()
251+
.all(|diag| diag.rule_id == "no-function-in-join-or-where"),);
252+
253+
assert_eq!(
254+
diagnostics.len(),
255+
2,
256+
"expected two diagnostics for functions on both sides"
257+
);
258+
259+
let spans: Vec<_> = diagnostics
260+
.iter()
261+
.map(|diag| &sql[diag.span.start.byte..diag.span.end.byte])
262+
.collect();
263+
assert!(
264+
spans.iter().any(|s| s.contains("trim(u1.email)")),
265+
"expected trim() function on left side to be detected"
266+
);
267+
assert!(
268+
spans.iter().any(|s| s.contains("trim(u2.email)")),
269+
"expected trim() function on right side to be detected"
270+
);
271+
}
272+
}
273+
274+
mod join_qual {
275+
276+
use super::*;
277+
278+
#[test]
279+
fn allows_function_with_constant() {
280+
let sql =
281+
"SELECT * FROM t1 JOIN t2 ON t1.created_at >= to_date('20160101', 'YYYYMMDD');";
282+
let diagnostics = run(sql);
283+
284+
assert!(diagnostics.is_empty(),);
285+
}
286+
287+
#[test]
288+
fn detects_function_in_join_condition() {
289+
let sql = "SELECT * FROM t1 JOIN t2 ON lower(t1.name) = t2.name;";
290+
let diagnostics = run(sql);
291+
292+
assert!(diagnostics
293+
.iter()
294+
.any(|diag| diag.rule_id == "no-function-in-join-or-where"),);
295+
296+
assert_eq!(diagnostics.len(), 1);
297+
298+
let SqlSpan { start, end } = diagnostics[0].span;
299+
assert_eq!(&sql[start.byte..end.byte], "lower(t1.name)");
300+
}
301+
302+
#[test]
303+
fn detects_function_on_both_sides() {
304+
let sql = "SELECT * FROM t1 JOIN t2 ON trim(t1.email) = trim(t2.email);";
305+
let diagnostics = run(sql);
306+
307+
assert!(diagnostics
308+
.iter()
309+
.all(|diag| diag.rule_id == "no-function-in-join-or-where"),);
310+
311+
assert_eq!(diagnostics.len(), 2,);
312+
313+
let spans: Vec<_> = diagnostics
314+
.iter()
315+
.map(|diag| &sql[diag.span.start.byte..diag.span.end.byte])
316+
.collect();
317+
assert!(
318+
spans.iter().any(|s| s.contains("trim(t1.email)")),
319+
"expected trim() function on left side to be detected"
320+
);
321+
assert!(
322+
spans.iter().any(|s| s.contains("trim(t2.email)")),
323+
"expected trim() function on right side to be detected"
324+
);
325+
}
326+
}
327+
328+
mod other_location {
329+
use super::*;
330+
331+
#[test]
332+
fn allows_function_in_select_column() {
333+
let sql = "SELECT func(col) FROM tbl;";
334+
let diagnostics = run(sql);
335+
336+
assert!(diagnostics.is_empty(),);
337+
}
338+
339+
#[test]
340+
fn allows_function_in_subquery() {
341+
let sql = "SELECT * FROM tbl WHERE col IN (SELECT func(col) FROM tbl);";
342+
let diagnostics = run(sql);
343+
344+
assert!(diagnostics.is_empty(),);
345+
}
346+
}
347+
}

0 commit comments

Comments
 (0)