Skip to content

Commit 5feb401

Browse files
committed
ruby: Add query for hoisting Rails ActiveRecord calls
This does not take assicoations into account. It uses ActiveRecordModelFinderCall to identify relevant calls. This class has therefor been made public.
1 parent d7117ef commit 5feb401

File tree

5 files changed

+136
-3
lines changed

5 files changed

+136
-3
lines changed

ruby/ql/lib/codeql/ruby/frameworks/ActiveRecord.qll

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -254,9 +254,8 @@ private Expr getUltimateReceiver(MethodCall call) {
254254
)
255255
}
256256

257-
// A call to `find`, `where`, etc. that may return active record model object(s)
258-
private class ActiveRecordModelFinderCall extends ActiveRecordModelInstantiation, DataFlow::CallNode
259-
{
257+
/** A call to `find`, `where`, etc. that may return active record model object(s) */
258+
class ActiveRecordModelFinderCall extends ActiveRecordModelInstantiation, DataFlow::CallNode {
260259
private ActiveRecordModelClass cls;
261260

262261
ActiveRecordModelFinderCall() {
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<!DOCTYPE qhelp PUBLIC
2+
"-//Semmle//qhelp//EN"
3+
"qhelp.dtd">
4+
<qhelp>
5+
6+
<overview>
7+
<p>
8+
When a Rails ActiveRecord query is executed in a loop, it is potentially an n+1 problem.
9+
This query identifies situations where an ActiveRecord query execution could be pulled out of a loop.
10+
</p>
11+
</overview>
12+
<recommendation>
13+
<p>If possible, pull the query out of the loop, thus replacing the many calls with a single one.
14+
</p>
15+
</recommendation>
16+
<example>
17+
<p>The following (suboptimal) example code queries the User object in each iteration of the loop:</p>
18+
<sample src="examples/straight_loop.rb" />
19+
<p>To improve the performance, we instead query the User object once outside the loop, gathereing all necessary information:</p>
20+
<sample src="examples/preload.rb" />
21+
</example>
22+
</qhelp>
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/**
2+
* @name Could be hoisted
3+
* @description Hoist Rails `ActiveRecord::Relation` query calls out of loops.
4+
* @kind problem
5+
* @problem.severity info
6+
* @precision high
7+
* @id rb/could-be-hoisted
8+
* @tags performance
9+
*/
10+
11+
// Possible Improvements;
12+
// - Consider also Associations.
13+
// Associations are lazy-loading by default, so something like
14+
// in a loop over `article` do
15+
// `article.book`
16+
// if you have 1000 articles it will do a 1000 calls to `book`.
17+
// If you already did `article includes book`, there should be no problem.
18+
// - Consider instances of ActiveRecordInstanceMethodCall, for instance
19+
// calls to `pluck`.
20+
import ruby
21+
private import codeql.ruby.AST
22+
import codeql.ruby.ast.internal.Constant
23+
import codeql.ruby.Concepts
24+
import codeql.ruby.frameworks.ActiveRecord
25+
private import codeql.ruby.TaintTracking
26+
27+
string loopMethodName() {
28+
result in [
29+
"each", "reverse_each", "map", "map!", "foreach", "flat_map", "in_batches", "one?", "all?",
30+
"collect", "collect!", "select", "select!", "reject", "reject!"
31+
]
32+
}
33+
34+
class LoopingCall extends DataFlow::CallNode {
35+
DataFlow::CallableNode loopBlock;
36+
37+
LoopingCall() {
38+
this.getMethodName() = loopMethodName() and loopBlock = this.getBlock().asCallable()
39+
}
40+
41+
DataFlow::CallableNode getLoopBlock() { result = loopBlock }
42+
}
43+
44+
predicate happensInLoop(LoopingCall loop, DataFlow::CallNode e) {
45+
loop.getLoopBlock().asCallableAstNode() = e.asExpr().getScope()
46+
}
47+
48+
predicate happensInOuterLoop(LoopingCall outerLoop, DataFlow::CallNode e) {
49+
exists(LoopingCall innerLoop |
50+
happensInLoop(outerLoop, innerLoop) and
51+
happensInLoop(innerLoop, e)
52+
)
53+
}
54+
55+
predicate happensInInnermostLoop(LoopingCall loop, DataFlow::CallNode e) {
56+
happensInLoop(loop, e) and
57+
not happensInOuterLoop(loop, e)
58+
}
59+
60+
// The ActiveRecord instance is used to potentially control the loop
61+
predicate usedInLoopControlGuard(ActiveRecordInstance ar, DataFlow::Node guard) {
62+
TaintTracking::localTaint(ar, guard) and
63+
guard = guardForLoopControl(_, _)
64+
}
65+
66+
// A guard for controlling the loop
67+
DataFlow::Node guardForLoopControl(ConditionalExpr cond, Stmt control) {
68+
result.asExpr().getAstNode() = cond.getCondition().getAChild*() and
69+
(
70+
control.(MethodCall).getMethodName() = "raise"
71+
or
72+
control instanceof NextStmt
73+
) and
74+
control = cond.getBranch(_).getAChild()
75+
}
76+
77+
from LoopingCall loop, DataFlow::CallNode call
78+
where
79+
// Disregard loops over constants
80+
not isArrayConstant(loop.getReceiver().asExpr(), _) and
81+
// Disregard tests
82+
not call.getLocation().getFile().getAbsolutePath().matches("%test%") and
83+
// Disregard cases where the looping is influenced by the query result
84+
not usedInLoopControlGuard(call, _) and
85+
// Only report the inner most loop
86+
happensInInnermostLoop(loop, call) and
87+
// Only report calls that are likely to be expensive
88+
call instanceof ActiveRecordModelFinderCall and
89+
not call.getMethodName() in ["new", "create"]
90+
select call, "This call happens inside $@, and could be hoisted.", loop, "this loop"
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Preload User data
2+
user_data = User.where(login: repo_names_by_owner.keys).pluck(:login, :id, :type).to_h do |login, id, type|
3+
[login, { id: id, type: type == "User" ? "USER" : "ORGANIZATION" }]
4+
end
5+
6+
repo_names_by_owner.each do |owner_slug, repo_names|
7+
owner_info = user_data[owner_slug]
8+
owner_id = owner_info[:id]
9+
owner_type = owner_info[:type]
10+
rel_conditions = { owner_id: owner_id, name: repo_names }
11+
12+
nwo_rel = nwo_rel.or(RepositorySecurityCenterConfig.where(rel_conditions)) unless neg
13+
nwo_rel = nwo_rel.and(RepositorySecurityCenterConfig.where.not(rel_conditions)) if neg
14+
end
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
repo_names_by_owner.map do |owner_slug, repo_names|
2+
owner_id, owner_type = User.where(login: owner_slug).pluck(:id, :type).first
3+
owner_type = owner_type == "User" ? "USER" : "ORGANIZATION"
4+
rel_conditions = { owner_id: owner_id, name: repo_names }
5+
6+
nwo_rel = nwo_rel.or(RepositorySecurityCenterConfig.where(rel_conditions)) unless neg
7+
nwo_rel = nwo_rel.and(RepositorySecurityCenterConfig.where.not(rel_conditions)) if neg
8+
end

0 commit comments

Comments
 (0)