eager_eye 1.0.10 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +3 -3
- data/CHANGELOG.md +20 -10
- data/CONTRIBUTING.md +1 -1
- data/README.md +20 -29
- data/SECURITY.md +1 -0
- data/lib/eager_eye/analyzer.rb +33 -14
- data/lib/eager_eye/association_parser.rb +88 -0
- data/lib/eager_eye/auto_fixer.rb +4 -12
- data/lib/eager_eye/cli.rb +2 -7
- data/lib/eager_eye/comment_parser.rb +0 -5
- data/lib/eager_eye/detectors/base.rb +25 -4
- data/lib/eager_eye/detectors/callback_query.rb +11 -45
- data/lib/eager_eye/detectors/count_in_iteration.rb +16 -54
- data/lib/eager_eye/detectors/custom_method_query.rb +25 -91
- data/lib/eager_eye/detectors/loop_association.rb +77 -89
- data/lib/eager_eye/detectors/missing_counter_cache.rb +16 -47
- data/lib/eager_eye/detectors/pluck_to_array.rb +19 -52
- data/lib/eager_eye/detectors/serializer_nesting.rb +20 -70
- data/lib/eager_eye/fixers/pluck_to_select.rb +2 -9
- data/lib/eager_eye/issue.rb +2 -9
- data/lib/eager_eye/railtie.rb +7 -20
- data/lib/eager_eye/reporters/console.rb +7 -18
- data/lib/eager_eye/rspec/matchers.rb +1 -6
- data/lib/eager_eye/version.rb +1 -1
- data/lib/eager_eye.rb +1 -1
- data/sig/eager_eye.rbs +0 -1
- metadata +3 -2
|
@@ -3,27 +3,9 @@
|
|
|
3
3
|
module EagerEye
|
|
4
4
|
module Detectors
|
|
5
5
|
class CustomMethodQuery < Base
|
|
6
|
-
QUERY_METHODS = %i[
|
|
7
|
-
|
|
8
|
-
find_by
|
|
9
|
-
find_by!
|
|
10
|
-
exists?
|
|
11
|
-
find
|
|
12
|
-
first
|
|
13
|
-
last
|
|
14
|
-
take
|
|
15
|
-
pluck
|
|
16
|
-
ids
|
|
17
|
-
count
|
|
18
|
-
sum
|
|
19
|
-
average
|
|
20
|
-
minimum
|
|
21
|
-
maximum
|
|
22
|
-
].freeze
|
|
23
|
-
|
|
24
|
-
# Array-only methods that should not be flagged when collection is clearly an array
|
|
6
|
+
QUERY_METHODS = %i[where find_by find_by! exists? find first last take pluck ids count sum average minimum
|
|
7
|
+
maximum].freeze
|
|
25
8
|
ARRAY_METHODS = %i[first last take].freeze
|
|
26
|
-
|
|
27
9
|
ITERATION_METHODS = %i[each map select find_all reject collect detect find_index flat_map].freeze
|
|
28
10
|
|
|
29
11
|
def self.detector_name
|
|
@@ -51,34 +33,22 @@ module EagerEye
|
|
|
51
33
|
|
|
52
34
|
if iteration_block?(node)
|
|
53
35
|
block_var = extract_block_variable(node)
|
|
54
|
-
block_body =
|
|
55
|
-
|
|
56
|
-
yield(block_body, block_var, collection) if block_var && block_body
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
node.children.each do |child|
|
|
60
|
-
find_iteration_blocks(child, &block)
|
|
36
|
+
block_body = node.children[2]
|
|
37
|
+
yield(block_body, block_var, node.children[0]) if block_var && block_body
|
|
61
38
|
end
|
|
39
|
+
node.children.each { |child| find_iteration_blocks(child, &block) }
|
|
62
40
|
end
|
|
63
41
|
|
|
64
42
|
def iteration_block?(node)
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
send_node = node.children[0]
|
|
68
|
-
return false unless send_node&.type == :send
|
|
69
|
-
|
|
70
|
-
method_name = send_node.children[1]
|
|
71
|
-
ITERATION_METHODS.include?(method_name)
|
|
43
|
+
node.type == :block && node.children[0]&.type == :send &&
|
|
44
|
+
ITERATION_METHODS.include?(node.children[0].children[1])
|
|
72
45
|
end
|
|
73
46
|
|
|
74
47
|
def check_block_for_query_methods(node, block_var, is_array_collection = false) # rubocop:disable Style/OptionalBooleanParameter
|
|
75
48
|
return unless node.is_a?(Parser::AST::Node)
|
|
76
49
|
|
|
77
50
|
add_issue(node) if query_chain_on_association?(node, block_var, is_array_collection)
|
|
78
|
-
|
|
79
|
-
node.children.each do |child|
|
|
80
|
-
check_block_for_query_methods(child, block_var, is_array_collection)
|
|
81
|
-
end
|
|
51
|
+
node.children.each { |child| check_block_for_query_methods(child, block_var, is_array_collection) }
|
|
82
52
|
end
|
|
83
53
|
|
|
84
54
|
def query_chain_on_association?(node, block_var, is_array_collection = false) # rubocop:disable Style/OptionalBooleanParameter
|
|
@@ -86,35 +56,23 @@ module EagerEye
|
|
|
86
56
|
|
|
87
57
|
method_name = node.children[1]
|
|
88
58
|
return false unless QUERY_METHODS.include?(method_name)
|
|
59
|
+
return false if is_array_collection && ARRAY_METHODS.include?(method_name) &&
|
|
60
|
+
receiver_is_only_block_var?(node.children[0], block_var)
|
|
89
61
|
|
|
90
|
-
|
|
91
|
-
# AND the receiver is only the block variable (not chained)
|
|
92
|
-
if is_array_collection && ARRAY_METHODS.include?(method_name) &&
|
|
93
|
-
receiver_is_only_block_var?(node.children[0], block_var)
|
|
94
|
-
return false
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
receiver = node.children[0]
|
|
98
|
-
receiver_chain_starts_with?(receiver, block_var)
|
|
62
|
+
receiver_chain_starts_with?(node.children[0], block_var)
|
|
99
63
|
end
|
|
100
64
|
|
|
101
65
|
def receiver_is_only_block_var?(node, block_var)
|
|
102
|
-
|
|
103
|
-
node.is_a?(Parser::AST::Node) &&
|
|
104
|
-
node.type == :lvar &&
|
|
105
|
-
node.children[0] == block_var
|
|
66
|
+
node.is_a?(Parser::AST::Node) && node.type == :lvar && node.children[0] == block_var
|
|
106
67
|
end
|
|
107
68
|
|
|
108
69
|
def receiver_chain_starts_with?(node, block_var)
|
|
109
70
|
return false unless node.is_a?(Parser::AST::Node)
|
|
110
71
|
|
|
111
72
|
case node.type
|
|
112
|
-
when :lvar
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
receiver_chain_starts_with?(node.children[0], block_var)
|
|
116
|
-
else
|
|
117
|
-
false
|
|
73
|
+
when :lvar then node.children[0] == block_var
|
|
74
|
+
when :send then receiver_chain_starts_with?(node.children[0], block_var)
|
|
75
|
+
else false
|
|
118
76
|
end
|
|
119
77
|
end
|
|
120
78
|
|
|
@@ -123,37 +81,16 @@ module EagerEye
|
|
|
123
81
|
return nil unless args_node&.type == :args
|
|
124
82
|
|
|
125
83
|
first_arg = args_node.children[0]
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
first_arg.children[0]
|
|
84
|
+
first_arg&.type == :arg ? first_arg.children[0] : nil
|
|
129
85
|
end
|
|
130
86
|
|
|
131
|
-
def
|
|
132
|
-
|
|
133
|
-
end
|
|
134
|
-
|
|
135
|
-
def extract_collection(block_node)
|
|
136
|
-
# Extract the collection being iterated on
|
|
137
|
-
# For: collection.each { |item| ... }
|
|
138
|
-
# Returns: the send node representing the collection method call
|
|
139
|
-
block_node.children[0]
|
|
140
|
-
end
|
|
141
|
-
|
|
142
|
-
def collection_is_array?(collection_node)
|
|
143
|
-
return false unless collection_node.is_a?(Parser::AST::Node)
|
|
87
|
+
def collection_is_array?(node)
|
|
88
|
+
return false unless node.is_a?(Parser::AST::Node)
|
|
144
89
|
|
|
145
|
-
case
|
|
146
|
-
when :array
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
when :send
|
|
150
|
-
# Only consider these methods as definitely returning arrays when iterating
|
|
151
|
-
method_name = collection_node.children[1]
|
|
152
|
-
# map, select, collect, etc. on anything return arrays for iteration
|
|
153
|
-
%i[map select collect flat_map to_a uniq compact].include?(method_name)
|
|
154
|
-
else
|
|
155
|
-
# Block variable itself won't tell us if it's an array
|
|
156
|
-
false
|
|
90
|
+
case node.type
|
|
91
|
+
when :array then true
|
|
92
|
+
when :send then %i[map select collect flat_map to_a uniq compact].include?(node.children[1])
|
|
93
|
+
else false
|
|
157
94
|
end
|
|
158
95
|
end
|
|
159
96
|
|
|
@@ -173,14 +110,11 @@ module EagerEye
|
|
|
173
110
|
return "" unless node.is_a?(Parser::AST::Node)
|
|
174
111
|
|
|
175
112
|
case node.type
|
|
176
|
-
when :lvar
|
|
177
|
-
node.children[0].to_s
|
|
113
|
+
when :lvar then node.children[0].to_s
|
|
178
114
|
when :send
|
|
179
115
|
receiver_str = reconstruct_chain(node.children[0])
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
else
|
|
183
|
-
""
|
|
116
|
+
receiver_str.empty? ? node.children[1].to_s : "#{receiver_str}.#{node.children[1]}"
|
|
117
|
+
else ""
|
|
184
118
|
end
|
|
185
119
|
end
|
|
186
120
|
end
|
|
@@ -5,11 +5,8 @@ module EagerEye
|
|
|
5
5
|
class LoopAssociation < Base
|
|
6
6
|
ITERATION_METHODS = %i[each map collect select find find_all reject filter filter_map flat_map].freeze
|
|
7
7
|
PRELOAD_METHODS = %i[includes preload eager_load].freeze
|
|
8
|
-
# Methods that return a single record (not a collection)
|
|
9
8
|
SINGLE_RECORD_METHODS = %i[find find_by find_by! first first! last last! take take! second third fourth fifth
|
|
10
9
|
forty_two sole find_sole_by].freeze
|
|
11
|
-
|
|
12
|
-
# Common association names (belongs_to = singular, has_many = plural)
|
|
13
10
|
ASSOCIATION_NAMES = Set.new(%w[
|
|
14
11
|
author user owner creator admin member customer client post article comment category tag
|
|
15
12
|
parent company organization project task item order product account profile setting image
|
|
@@ -18,8 +15,6 @@ module EagerEye
|
|
|
18
15
|
tasks items orders products accounts profiles settings images avatars photos attachments
|
|
19
16
|
documents
|
|
20
17
|
]).freeze
|
|
21
|
-
|
|
22
|
-
# Methods that should NOT be treated as associations
|
|
23
18
|
EXCLUDED_METHODS = %i[
|
|
24
19
|
id to_s to_h to_a to_json to_xml inspect class object_id nil? blank? present? empty?
|
|
25
20
|
any? none? size count length save save! update update! destroy destroy! delete delete!
|
|
@@ -31,10 +26,11 @@ module EagerEye
|
|
|
31
26
|
:loop_association
|
|
32
27
|
end
|
|
33
28
|
|
|
34
|
-
def detect(ast, file_path)
|
|
29
|
+
def detect(ast, file_path, association_preloads = {})
|
|
35
30
|
return [] unless ast
|
|
36
31
|
|
|
37
32
|
issues = []
|
|
33
|
+
@association_preloads = association_preloads
|
|
38
34
|
build_variable_maps(ast)
|
|
39
35
|
|
|
40
36
|
traverse_ast(ast) do |node|
|
|
@@ -51,6 +47,8 @@ module EagerEye
|
|
|
51
47
|
|
|
52
48
|
included = extract_included_associations(collection_node)
|
|
53
49
|
included.merge(extract_variable_preloads(collection_node))
|
|
50
|
+
model_name = infer_model_name_from_collection(collection_node)
|
|
51
|
+
included.merge(get_association_preloads(model_name))
|
|
54
52
|
|
|
55
53
|
find_association_calls(block_body, block_var, file_path, issues, included)
|
|
56
54
|
end
|
|
@@ -60,40 +58,42 @@ module EagerEye
|
|
|
60
58
|
|
|
61
59
|
private
|
|
62
60
|
|
|
63
|
-
def
|
|
64
|
-
|
|
61
|
+
def get_association_preloads(model_name)
|
|
62
|
+
key = "#{model_name}#*"
|
|
63
|
+
preloaded = Set.new
|
|
64
|
+
@association_preloads&.each do |assoc_key, assocs|
|
|
65
|
+
preloaded.merge(assocs) if assoc_key.start_with?(key)
|
|
66
|
+
end
|
|
67
|
+
preloaded
|
|
68
|
+
end
|
|
65
69
|
|
|
66
|
-
|
|
67
|
-
return
|
|
70
|
+
def infer_model_name_from_collection(node)
|
|
71
|
+
return nil unless node&.type == :send
|
|
68
72
|
|
|
69
|
-
|
|
70
|
-
|
|
73
|
+
receiver = node.children[0]
|
|
74
|
+
receiver.children[1].to_s if receiver&.type == :const
|
|
71
75
|
end
|
|
72
76
|
|
|
73
|
-
def
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
first_arg = args_node.children[0]
|
|
79
|
-
return nil unless first_arg&.type == :arg
|
|
77
|
+
def iteration_block?(node)
|
|
78
|
+
node.type == :block && node.children[0]&.type == :send &&
|
|
79
|
+
ITERATION_METHODS.include?(node.children[0].children[1])
|
|
80
|
+
end
|
|
80
81
|
|
|
81
|
-
|
|
82
|
+
def extract_block_variable(block_node)
|
|
83
|
+
args = block_node&.children&.[](1)
|
|
84
|
+
first_arg = args&.children&.first
|
|
85
|
+
first_arg&.type == :arg ? first_arg.children[0] : nil
|
|
82
86
|
end
|
|
83
87
|
|
|
84
88
|
def extract_included_associations(collection_node)
|
|
85
89
|
included = Set.new
|
|
86
90
|
return included unless collection_node&.type == :send
|
|
87
91
|
|
|
88
|
-
# Traverse through chained method calls to find includes/preload/eager_load
|
|
89
92
|
current = collection_node
|
|
90
93
|
while current&.type == :send
|
|
91
|
-
|
|
92
|
-
extract_includes_from_method(current, included) if PRELOAD_METHODS.include?(method_name)
|
|
93
|
-
|
|
94
|
+
extract_includes_from_method(current, included) if PRELOAD_METHODS.include?(current.children[1])
|
|
94
95
|
current = current.children[0]
|
|
95
96
|
end
|
|
96
|
-
|
|
97
97
|
included
|
|
98
98
|
end
|
|
99
99
|
|
|
@@ -101,19 +101,21 @@ module EagerEye
|
|
|
101
101
|
@variable_preloads = {}
|
|
102
102
|
@single_record_variables = Set.new
|
|
103
103
|
|
|
104
|
-
traverse_ast(ast)
|
|
105
|
-
|
|
104
|
+
traverse_ast(ast) { |node| process_variable_assignment(node) }
|
|
105
|
+
end
|
|
106
106
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
value_node = node.children[1]
|
|
110
|
-
next unless value_node
|
|
107
|
+
def process_variable_assignment(node)
|
|
108
|
+
return unless %i[lvasgn ivasgn].include?(node.type)
|
|
111
109
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
110
|
+
var_type = node.type == :lvasgn ? :lvar : :ivar
|
|
111
|
+
var_name = node.children[0]
|
|
112
|
+
value_node = node.children[1]
|
|
113
|
+
return unless value_node
|
|
114
|
+
|
|
115
|
+
key = [var_type, var_name]
|
|
116
|
+
preloaded = extract_included_associations(value_node)
|
|
117
|
+
@variable_preloads[key] = preloaded unless preloaded.empty?
|
|
118
|
+
@single_record_variables.add(key) if single_record_query?(value_node)
|
|
117
119
|
end
|
|
118
120
|
|
|
119
121
|
def extract_variable_preloads(node)
|
|
@@ -130,11 +132,18 @@ module EagerEye
|
|
|
130
132
|
end
|
|
131
133
|
|
|
132
134
|
def single_record_query?(node)
|
|
135
|
+
last_send = find_last_send_method(node)
|
|
136
|
+
last_send && SINGLE_RECORD_METHODS.include?(last_send)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def find_last_send_method(node)
|
|
133
140
|
current = node
|
|
134
|
-
while current&.type == :send && !
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
141
|
+
current = current.children[0] while current&.type == :send && !single_record_method?(current)
|
|
142
|
+
current&.type == :send ? current.children[1] : nil
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def single_record_method?(node)
|
|
146
|
+
SINGLE_RECORD_METHODS.include?(node.children[1])
|
|
138
147
|
end
|
|
139
148
|
|
|
140
149
|
def single_record_iteration?(node)
|
|
@@ -145,67 +154,46 @@ module EagerEye
|
|
|
145
154
|
end
|
|
146
155
|
|
|
147
156
|
def extract_includes_from_method(method_node, included_set)
|
|
148
|
-
args = method_node
|
|
149
|
-
args
|
|
150
|
-
case arg&.type
|
|
151
|
-
when :sym
|
|
152
|
-
# includes(:product)
|
|
153
|
-
included_set.add(arg.children[0])
|
|
154
|
-
when :hash
|
|
155
|
-
# includes(product: :manufacturer)
|
|
156
|
-
extract_from_hash(arg, included_set)
|
|
157
|
-
end
|
|
158
|
-
end
|
|
159
|
-
end
|
|
160
|
-
|
|
161
|
-
def extract_from_hash(hash_node, included_set)
|
|
162
|
-
hash_node.children.each do |pair|
|
|
163
|
-
key = pair.children[0]
|
|
164
|
-
included_set.add(key.children[0]) if key&.type == :sym
|
|
165
|
-
end
|
|
157
|
+
args = extract_method_args(method_node)
|
|
158
|
+
included_set.merge(extract_symbols_from_args(args))
|
|
166
159
|
end
|
|
167
160
|
|
|
168
161
|
def find_association_calls(node, block_var, file_path, issues, included_associations = Set.new)
|
|
169
|
-
|
|
170
|
-
|
|
162
|
+
reported = Set.new
|
|
171
163
|
traverse_ast(node) do |child|
|
|
172
|
-
next unless child
|
|
173
|
-
|
|
174
|
-
receiver = child.children[0]
|
|
175
|
-
method_name = child.children[1]
|
|
176
|
-
|
|
177
|
-
# Only detect direct calls on block variable (post.author, not post.author.name)
|
|
178
|
-
next unless direct_call_on_block_var?(receiver, block_var)
|
|
179
|
-
next unless likely_association?(method_name)
|
|
180
|
-
|
|
181
|
-
# Skip if association is already included
|
|
182
|
-
next if included_associations.include?(method_name)
|
|
164
|
+
next unless should_report_issue?(child, block_var, reported, included_associations)
|
|
183
165
|
|
|
184
|
-
|
|
185
|
-
report_key = "#{child.loc.line}:#{method_name}"
|
|
186
|
-
next if reported_associations.include?(report_key)
|
|
187
|
-
|
|
188
|
-
reported_associations << report_key
|
|
189
|
-
|
|
190
|
-
issues << create_issue(
|
|
191
|
-
file_path: file_path,
|
|
192
|
-
line_number: child.loc.line,
|
|
193
|
-
message: "Potential N+1 query: `#{block_var}.#{method_name}` called inside iteration",
|
|
194
|
-
suggestion: "Consider using `includes(:#{method_name})` on the collection before iterating"
|
|
195
|
-
)
|
|
166
|
+
add_n_plus_one_issue(child, block_var, file_path, issues, reported)
|
|
196
167
|
end
|
|
197
168
|
end
|
|
198
169
|
|
|
199
|
-
def
|
|
200
|
-
return false unless
|
|
170
|
+
def should_report_issue?(child, block_var, reported, included)
|
|
171
|
+
return false unless child.type == :send
|
|
201
172
|
|
|
202
|
-
receiver
|
|
173
|
+
receiver = child.children[0]
|
|
174
|
+
method = child.children[1]
|
|
175
|
+
return false unless receiver&.type == :lvar && receiver.children[0] == block_var
|
|
176
|
+
return false if excluded?(method, included)
|
|
177
|
+
|
|
178
|
+
key = "#{child.loc.line}:#{method}"
|
|
179
|
+
!reported.include?(key) && reported.add(key)
|
|
203
180
|
end
|
|
204
181
|
|
|
205
|
-
def
|
|
206
|
-
|
|
182
|
+
def excluded?(method, included)
|
|
183
|
+
EXCLUDED_METHODS.include?(method) ||
|
|
184
|
+
!ASSOCIATION_NAMES.include?(method.to_s) ||
|
|
185
|
+
included.include?(method)
|
|
186
|
+
end
|
|
207
187
|
|
|
208
|
-
|
|
188
|
+
def add_n_plus_one_issue(node, block_var, file_path, issues, reported)
|
|
189
|
+
method = node.children[1]
|
|
190
|
+
reported << "#{node.loc.line}:#{method}"
|
|
191
|
+
issues << create_issue(
|
|
192
|
+
file_path: file_path,
|
|
193
|
+
line_number: node.loc.line,
|
|
194
|
+
message: "Potential N+1 query: `#{block_var}.#{method}` called inside loop",
|
|
195
|
+
suggestion: "Use `includes(:#{method})` before iterating"
|
|
196
|
+
)
|
|
209
197
|
end
|
|
210
198
|
end
|
|
211
199
|
end
|
|
@@ -3,22 +3,14 @@
|
|
|
3
3
|
module EagerEye
|
|
4
4
|
module Detectors
|
|
5
5
|
class MissingCounterCache < Base
|
|
6
|
-
# Methods that trigger COUNT queries
|
|
7
6
|
COUNT_METHODS = %i[count size length].freeze
|
|
8
|
-
|
|
9
|
-
# Common has_many association names (plural)
|
|
10
7
|
PLURAL_ASSOCIATIONS = %w[
|
|
11
|
-
posts comments tags categories articles users members
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
followers followings likes favorites bookmarks votes
|
|
15
|
-
children replies responses answers questions
|
|
8
|
+
posts comments tags categories articles users members items orders products tasks projects
|
|
9
|
+
images attachments documents files messages notifications reviews ratings followers followings
|
|
10
|
+
likes favorites bookmarks votes children replies responses answers questions
|
|
16
11
|
].freeze
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
ITERATION_METHODS = %i[each map collect select reject find_all
|
|
20
|
-
filter filter_map flat_map each_with_index
|
|
21
|
-
each_with_object reduce inject sum].freeze
|
|
12
|
+
ITERATION_METHODS = %i[each map collect select reject find_all filter filter_map flat_map
|
|
13
|
+
each_with_index each_with_object reduce inject sum].freeze
|
|
22
14
|
|
|
23
15
|
def self.detector_name
|
|
24
16
|
:missing_counter_cache
|
|
@@ -51,68 +43,45 @@ module EagerEye
|
|
|
51
43
|
|
|
52
44
|
def count_on_association?(node)
|
|
53
45
|
return false unless node.type == :send
|
|
54
|
-
|
|
55
|
-
method_name = node.children[1]
|
|
56
|
-
return false unless COUNT_METHODS.include?(method_name)
|
|
46
|
+
return false unless COUNT_METHODS.include?(node.children[1])
|
|
57
47
|
|
|
58
48
|
receiver = node.children[0]
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
likely_association_receiver?(receiver)
|
|
49
|
+
receiver && likely_association_receiver?(receiver)
|
|
62
50
|
end
|
|
63
51
|
|
|
64
52
|
def likely_association_receiver?(node)
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
method_name = node.children[1]
|
|
68
|
-
PLURAL_ASSOCIATIONS.include?(method_name.to_s)
|
|
53
|
+
node.type == :send && PLURAL_ASSOCIATIONS.include?(node.children[1].to_s)
|
|
69
54
|
end
|
|
70
55
|
|
|
71
56
|
def extract_association_name(node)
|
|
72
57
|
receiver = node.children[0]
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
receiver.children[1].to_s
|
|
58
|
+
receiver.children[1].to_s if receiver&.type == :send
|
|
76
59
|
end
|
|
77
60
|
|
|
78
|
-
# Check if the node is inside an iteration block
|
|
79
61
|
def inside_iteration?(node)
|
|
80
62
|
parent = node
|
|
81
|
-
while (parent =
|
|
63
|
+
while (parent = @parent_map[parent])
|
|
82
64
|
return true if iteration_block?(parent)
|
|
83
65
|
end
|
|
84
66
|
false
|
|
85
67
|
end
|
|
86
68
|
|
|
87
|
-
def find_parent(node)
|
|
88
|
-
@parent_map ||= {}
|
|
89
|
-
@parent_map[node]
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
# Override traverse_ast to build parent map
|
|
93
69
|
def traverse_ast(node, &block)
|
|
94
70
|
return unless node.is_a?(Parser::AST::Node)
|
|
95
71
|
|
|
96
72
|
@parent_map ||= {}
|
|
97
|
-
|
|
98
73
|
yield node
|
|
99
|
-
|
|
100
74
|
node.children.each do |child|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
75
|
+
next unless child.is_a?(Parser::AST::Node)
|
|
76
|
+
|
|
77
|
+
@parent_map[child] = node
|
|
78
|
+
traverse_ast(child, &block)
|
|
105
79
|
end
|
|
106
80
|
end
|
|
107
81
|
|
|
108
82
|
def iteration_block?(node)
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
send_node = node.children[0]
|
|
112
|
-
return false unless send_node&.type == :send
|
|
113
|
-
|
|
114
|
-
method_name = send_node.children[1]
|
|
115
|
-
ITERATION_METHODS.include?(method_name)
|
|
83
|
+
node.type == :block && node.children[0]&.type == :send &&
|
|
84
|
+
ITERATION_METHODS.include?(node.children[0].children[1])
|
|
116
85
|
end
|
|
117
86
|
end
|
|
118
87
|
end
|
|
@@ -49,108 +49,75 @@ module EagerEye
|
|
|
49
49
|
add_issue(node) if regular_pluck?(node)
|
|
50
50
|
end
|
|
51
51
|
|
|
52
|
-
def local_variable_assignment?(node)
|
|
53
|
-
node.type == :lvasgn
|
|
54
|
-
end
|
|
52
|
+
def local_variable_assignment?(node) = node.type == :lvasgn
|
|
55
53
|
|
|
56
|
-
def where_call?(node)
|
|
57
|
-
node.type == :send && node.children[1] == :where
|
|
58
|
-
end
|
|
54
|
+
def where_call?(node) = node.type == :send && node.children[1] == :where
|
|
59
55
|
|
|
60
56
|
def pluck_call?(node)
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
method = node.children[1]
|
|
64
|
-
%i[pluck ids].include?(method)
|
|
57
|
+
node.is_a?(Parser::AST::Node) && node.type == :send && %i[pluck ids].include?(node.children[1])
|
|
65
58
|
end
|
|
66
59
|
|
|
67
60
|
def all_pluck_call?(node)
|
|
68
61
|
return false unless pluck_call?(node)
|
|
69
62
|
|
|
70
63
|
receiver = node.children[0]
|
|
71
|
-
receiver.is_a?(Parser::AST::Node) && receiver.type == :send &&
|
|
72
|
-
receiver.children[1] == :all
|
|
64
|
+
receiver.is_a?(Parser::AST::Node) && receiver.type == :send && receiver.children[1] == :all
|
|
73
65
|
end
|
|
74
66
|
|
|
75
67
|
def map_id_call?(node)
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
block_map?(node) || send_map?(node)
|
|
68
|
+
node.is_a?(Parser::AST::Node) && (block_map?(node) || send_map?(node))
|
|
79
69
|
end
|
|
80
70
|
|
|
81
71
|
def block_map?(node)
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
send_node = node.children[0]
|
|
85
|
-
send_node&.type == :send && %i[map collect].include?(send_node.children[1])
|
|
72
|
+
node.type == :block && node.children[0]&.type == :send &&
|
|
73
|
+
%i[map collect].include?(node.children[0].children[1])
|
|
86
74
|
end
|
|
87
75
|
|
|
88
76
|
def send_map?(node)
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
method = node.children[1]
|
|
92
|
-
%i[map collect].include?(method) &&
|
|
77
|
+
node.type == :send && %i[map collect].include?(node.children[1]) &&
|
|
93
78
|
node.children[2..].any? { |arg| symbol_to_proc_id?(arg) }
|
|
94
79
|
end
|
|
95
80
|
|
|
96
81
|
def symbol_to_proc_id?(node)
|
|
97
82
|
return false unless node.is_a?(Parser::AST::Node) && node.type == :block_pass
|
|
98
83
|
|
|
99
|
-
sym
|
|
100
|
-
sym&.type == :sym && %i[id to_i].include?(sym.children[0])
|
|
84
|
+
node.children[0]&.type == :sym && %i[id to_i].include?(node.children[0].children[0])
|
|
101
85
|
end
|
|
102
86
|
|
|
103
87
|
def regular_pluck?(node)
|
|
104
|
-
|
|
105
|
-
where_args.any? { |arg| pluck_var_in_hash?(arg) }
|
|
88
|
+
node.children[2..].any? { |arg| pluck_var_in_hash?(arg) }
|
|
106
89
|
end
|
|
107
90
|
|
|
108
91
|
def critical_pluck?(node)
|
|
109
|
-
|
|
110
|
-
where_args.any? { |arg| critical_pluck_in_hash?(arg) }
|
|
92
|
+
node.children[2..].any? { |arg| critical_pluck_in_hash?(arg) }
|
|
111
93
|
end
|
|
112
94
|
|
|
113
95
|
def pluck_var_in_hash?(node)
|
|
114
96
|
return false unless node.is_a?(Parser::AST::Node) && node.type == :hash
|
|
115
97
|
|
|
116
|
-
node.children.any?
|
|
117
|
-
next false unless pair.type == :pair
|
|
118
|
-
|
|
119
|
-
pluck_value?(pair.children[1])
|
|
120
|
-
end
|
|
98
|
+
node.children.any? { |pair| pair.type == :pair && pluck_value?(pair.children[1]) }
|
|
121
99
|
end
|
|
122
100
|
|
|
123
101
|
def critical_pluck_in_hash?(node)
|
|
124
102
|
return false unless node.is_a?(Parser::AST::Node) && node.type == :hash
|
|
125
103
|
|
|
126
|
-
node.children.any?
|
|
127
|
-
next false unless pair.type == :pair
|
|
128
|
-
|
|
129
|
-
critical_value?(pair.children[1])
|
|
130
|
-
end
|
|
104
|
+
node.children.any? { |pair| pair.type == :pair && critical_value?(pair.children[1]) }
|
|
131
105
|
end
|
|
132
106
|
|
|
133
107
|
def pluck_value?(value)
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
var_name = value.children[0]
|
|
137
|
-
@pluck_variables.key?(var_name) || @map_id_variables.key?(var_name)
|
|
108
|
+
value.type == :lvar && (@pluck_variables.key?(value.children[0]) || @map_id_variables.key?(value.children[0]))
|
|
138
109
|
end
|
|
139
110
|
|
|
140
111
|
def critical_value?(value)
|
|
141
|
-
|
|
142
|
-
@critical_pluck_variables.key?(value.children[0])
|
|
143
|
-
else
|
|
144
|
-
all_pluck_call?(value)
|
|
145
|
-
end
|
|
112
|
+
value.type == :lvar ? @critical_pluck_variables.key?(value.children[0]) : all_pluck_call?(value)
|
|
146
113
|
end
|
|
147
114
|
|
|
148
115
|
def add_issue(node)
|
|
149
116
|
@issues << create_issue(
|
|
150
117
|
file_path: @file_path,
|
|
151
118
|
line_number: node.loc.line,
|
|
152
|
-
message: "Using plucked
|
|
153
|
-
suggestion: "Use `.select(:id)` subquery: `Model.where(col: OtherModel.
|
|
119
|
+
message: "Using plucked array in `where` causes two queries and memory overhead",
|
|
120
|
+
suggestion: "Use `.select(:id)` subquery instead: `Model.where(col: OtherModel.select(:id))`",
|
|
154
121
|
severity: :warning
|
|
155
122
|
)
|
|
156
123
|
end
|
|
@@ -159,8 +126,8 @@ module EagerEye
|
|
|
159
126
|
@issues << create_issue(
|
|
160
127
|
file_path: @file_path,
|
|
161
128
|
line_number: node.loc.line,
|
|
162
|
-
message: "Using `.all.pluck(:id)`
|
|
163
|
-
suggestion: "Use `.select(:id)` subquery
|
|
129
|
+
message: "Using `.all.pluck(:id)` loads entire table into memory - highly inefficient",
|
|
130
|
+
suggestion: "Use `.select(:id)` subquery: `Model.where(col: OtherModel.select(:id))`",
|
|
164
131
|
severity: :error
|
|
165
132
|
)
|
|
166
133
|
end
|