eager_eye 1.2.0 → 1.2.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 +1 -1
- data/CHANGELOG.md +12 -0
- data/README.md +1 -1
- data/lib/eager_eye/detectors/concerns/non_ar_source_detector.rb +56 -0
- data/lib/eager_eye/detectors/pluck_to_array.rb +130 -84
- data/lib/eager_eye/version.rb +1 -1
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2e098f00c3f4cffd7e4450d5fd8e2adaa01d9e1fca4d7180d996e6b9c646a448
|
|
4
|
+
data.tar.gz: 4a2abc06d7986eaa0629e076ffc7950dfe0a7af81f051bee9faee608ee5f0779
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b7683c5e0209f89058a99a0e4fd438f2253c231df17646baf7cfe59a18c2b617ebe9664ef10cc7e0243ae0cd863056940ba792ba82c671c908b757f5774ac04f
|
|
7
|
+
data.tar.gz: 489810690ff2bef7ccb7c36c55367ffacbedbe9191393d0b27eeb7133457d896096f54d593133944b6f265f97d4aadec96464811975bcaea7ab0f3108d2ce3b5
|
data/.rubocop.yml
CHANGED
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.2.1] - 2026-01-31
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **PluckToArray False Positives** - Major improvements to reduce false positives:
|
|
15
|
+
- Skip when variable is used in multiple places (e.g., for ordering with `array_position`)
|
|
16
|
+
- Skip non-ActiveRecord sources: Sidekiq, Redis, Resque, DelayedJob
|
|
17
|
+
- Skip `.to_sql` usage patterns (UNION queries can't use subqueries)
|
|
18
|
+
- Skip non-AR `.where` receivers (Sidekiq::Queue, Redis, etc.)
|
|
19
|
+
- Differentiate message between `.pluck()` and `.map(&:id)` patterns
|
|
20
|
+
- Skip block maps like `.map { |u| u[:id] }` (likely Hash/Array access)
|
|
21
|
+
|
|
10
22
|
## [1.2.0] - 2026-01-18
|
|
11
23
|
|
|
12
24
|
### Features
|
data/README.md
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
<p align="center">
|
|
12
12
|
<a href="https://github.com/hamzagedikkaya/eager_eye/actions/workflows/main.yml"><img src="https://github.com/hamzagedikkaya/eager_eye/actions/workflows/main.yml/badge.svg" alt="CI"></a>
|
|
13
|
-
<a href="https://rubygems.org/gems/eager_eye"><img src="https://img.shields.io/badge/gem-v1.2.
|
|
13
|
+
<a href="https://rubygems.org/gems/eager_eye"><img src="https://img.shields.io/badge/gem-v1.2.1-red.svg" alt="Gem Version"></a>
|
|
14
14
|
<a href="https://github.com/hamzagedikkaya/eager_eye"><img src="https://img.shields.io/badge/coverage-95%25-brightgreen.svg" alt="Coverage"></a>
|
|
15
15
|
<a href="https://www.ruby-lang.org/"><img src="https://img.shields.io/badge/ruby-%3E%3D%203.1-ruby.svg" alt="Ruby"></a>
|
|
16
16
|
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT"></a>
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EagerEye
|
|
4
|
+
module Detectors
|
|
5
|
+
module Concerns
|
|
6
|
+
module NonArSourceDetector
|
|
7
|
+
NON_AR_RECEIVERS = %w[Sidekiq Redis Resque DelayedJob Queue Job Hash Array Set].freeze
|
|
8
|
+
NON_DB_SOURCE_METHODS = %i[smembers sinter sunion sdiff zrange zrangebyscore lrange hkeys hvals hgetall
|
|
9
|
+
keys values entries args].freeze
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def ar_receiver?(node)
|
|
14
|
+
receiver = node.children[0]
|
|
15
|
+
return true unless receiver.is_a?(Parser::AST::Node)
|
|
16
|
+
|
|
17
|
+
!non_ar_class?(receiver)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def non_ar_class?(node)
|
|
21
|
+
return false unless node.is_a?(Parser::AST::Node)
|
|
22
|
+
return NON_AR_RECEIVERS.any? { |r| extract_const_name(node).include?(r) } if node.type == :const
|
|
23
|
+
return non_ar_class?(node.children[0]) if node.type == :send && node.children[0].is_a?(Parser::AST::Node)
|
|
24
|
+
|
|
25
|
+
false
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def extract_const_name(node)
|
|
29
|
+
return "" unless node.is_a?(Parser::AST::Node) && node.type == :const
|
|
30
|
+
|
|
31
|
+
parent_name = extract_const_name(node.children[0])
|
|
32
|
+
name = node.children[1].to_s
|
|
33
|
+
parent_name.empty? ? name : "#{parent_name}::#{name}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def non_db_source?(node)
|
|
37
|
+
return false unless node.is_a?(Parser::AST::Node)
|
|
38
|
+
return false unless %i[send block].include?(node.type)
|
|
39
|
+
|
|
40
|
+
send_node = node.type == :block ? node.children[0] : node
|
|
41
|
+
send_node.is_a?(Parser::AST::Node) && non_db_method_chain?(send_node)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def non_db_method_chain?(node)
|
|
45
|
+
return false unless node.is_a?(Parser::AST::Node) && node.type == :send
|
|
46
|
+
return true if NON_DB_SOURCE_METHODS.include?(node.children[1])
|
|
47
|
+
|
|
48
|
+
receiver = node.children[0]
|
|
49
|
+
return non_ar_class?(receiver) || non_db_method_chain?(receiver) if receiver.is_a?(Parser::AST::Node)
|
|
50
|
+
|
|
51
|
+
false
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "concerns/non_ar_source_detector"
|
|
4
|
+
|
|
3
5
|
module EagerEye
|
|
4
6
|
module Detectors
|
|
5
7
|
class PluckToArray < Base
|
|
8
|
+
include Concerns::NonArSourceDetector
|
|
9
|
+
|
|
6
10
|
SMALL_COLLECTIONS = %w[tags settings options categories roles permissions statuses types priorities].freeze
|
|
7
11
|
|
|
8
12
|
def self.detector_name
|
|
@@ -12,53 +16,100 @@ module EagerEye
|
|
|
12
16
|
def detect(ast, file_path)
|
|
13
17
|
@issues = []
|
|
14
18
|
@file_path = file_path
|
|
15
|
-
|
|
16
|
-
@map_id_variables = {}
|
|
17
|
-
@critical_pluck_variables = {}
|
|
18
|
-
@small_collection_variables = {}
|
|
19
|
+
reset_tracking_variables
|
|
19
20
|
|
|
20
21
|
return @issues unless ast
|
|
21
22
|
|
|
22
|
-
|
|
23
|
+
collect_all_info(ast)
|
|
24
|
+
check_ast(ast)
|
|
25
|
+
|
|
23
26
|
@issues
|
|
24
27
|
end
|
|
25
28
|
|
|
26
29
|
private
|
|
27
30
|
|
|
28
|
-
def
|
|
31
|
+
def reset_tracking_variables
|
|
32
|
+
@pluck_variables = {}
|
|
33
|
+
@map_id_variables = {}
|
|
34
|
+
@critical_pluck_variables = {}
|
|
35
|
+
@small_collection_variables = {}
|
|
36
|
+
@variable_usages = Hash.new(0)
|
|
37
|
+
@to_sql_variables = {}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def collect_all_info(node)
|
|
29
41
|
return unless node.is_a?(Parser::AST::Node)
|
|
30
42
|
|
|
31
43
|
collect_assignments(node)
|
|
32
|
-
|
|
44
|
+
collect_variable_usage(node)
|
|
45
|
+
collect_to_sql_usage(node)
|
|
33
46
|
|
|
34
|
-
node.children.each { |child|
|
|
47
|
+
node.children.each { |child| collect_all_info(child) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def check_ast(node)
|
|
51
|
+
return unless node.is_a?(Parser::AST::Node)
|
|
52
|
+
|
|
53
|
+
check_where_calls(node)
|
|
54
|
+
node.children.each { |child| check_ast(child) }
|
|
35
55
|
end
|
|
36
56
|
|
|
37
57
|
def collect_assignments(node)
|
|
38
|
-
return unless
|
|
58
|
+
return unless node.type == :lvasgn
|
|
39
59
|
|
|
40
60
|
var_name = node.children[0]
|
|
41
61
|
value = node.children[1]
|
|
42
62
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
63
|
+
return if non_db_source?(value)
|
|
64
|
+
|
|
65
|
+
track_variable_type(var_name, value, node.loc.line)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def track_variable_type(var_name, value, line)
|
|
69
|
+
@critical_pluck_variables[var_name] = line if all_pluck_call?(value)
|
|
70
|
+
@small_collection_variables[var_name] = line if small_collection_pluck?(value)
|
|
71
|
+
@pluck_variables[var_name] = line if pluck_call?(value)
|
|
72
|
+
@map_id_variables[var_name] = line if map_id_call?(value)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def collect_variable_usage(node)
|
|
76
|
+
return unless node.is_a?(Parser::AST::Node) && node.type == :lvar
|
|
77
|
+
|
|
78
|
+
@variable_usages[node.children[0]] += 1
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def collect_to_sql_usage(node)
|
|
82
|
+
return unless node.is_a?(Parser::AST::Node) && node.type == :send && node.children[1] == :to_sql
|
|
83
|
+
|
|
84
|
+
find_variables_in_chain(node).each { |var_name| @to_sql_variables[var_name] = true }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def find_variables_in_chain(node)
|
|
88
|
+
return [] unless node.is_a?(Parser::AST::Node)
|
|
89
|
+
return [node.children[0]] if node.type == :lvar
|
|
90
|
+
|
|
91
|
+
node.children.flat_map { |child| find_variables_in_chain(child) }
|
|
47
92
|
end
|
|
48
93
|
|
|
49
94
|
def check_where_calls(node)
|
|
50
|
-
return unless where_call?(node)
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
95
|
+
return unless where_call?(node) && ar_receiver?(node)
|
|
96
|
+
|
|
97
|
+
process_where_call(node)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def process_where_call(node)
|
|
101
|
+
if critical_pluck?(node) then add_critical_issue(node)
|
|
102
|
+
elsif small_collection?(node) then add_info_issue(node)
|
|
103
|
+
elsif regular_pluck?(node) then check_regular_pluck(node)
|
|
58
104
|
end
|
|
59
105
|
end
|
|
60
106
|
|
|
61
|
-
def
|
|
107
|
+
def check_regular_pluck(node)
|
|
108
|
+
var_name = find_pluck_var_in_where(node)
|
|
109
|
+
return if var_name && (multi_use_variable?(var_name) || @to_sql_variables[var_name])
|
|
110
|
+
|
|
111
|
+
add_issue(node, var_name)
|
|
112
|
+
end
|
|
62
113
|
|
|
63
114
|
def where_call?(node) = node.type == :send && node.children[1] == :where
|
|
64
115
|
|
|
@@ -67,108 +118,103 @@ module EagerEye
|
|
|
67
118
|
end
|
|
68
119
|
|
|
69
120
|
def all_pluck_call?(node)
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
receiver = node.children[0]
|
|
73
|
-
receiver.is_a?(Parser::AST::Node) && receiver.type == :send && receiver.children[1] == :all
|
|
121
|
+
pluck_call?(node) && node.children[0].is_a?(Parser::AST::Node) &&
|
|
122
|
+
node.children[0].type == :send && node.children[0].children[1] == :all
|
|
74
123
|
end
|
|
75
124
|
|
|
76
125
|
def small_collection_pluck?(node)
|
|
77
126
|
return false unless pluck_call?(node)
|
|
78
127
|
|
|
79
128
|
receiver = node.children[0]
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
method_name = receiver.children[1].to_s
|
|
83
|
-
SMALL_COLLECTIONS.any? { |c| method_name.include?(c) }
|
|
129
|
+
receiver.is_a?(Parser::AST::Node) && receiver.type == :send &&
|
|
130
|
+
SMALL_COLLECTIONS.any? { |c| receiver.children[1].to_s.include?(c) }
|
|
84
131
|
end
|
|
85
132
|
|
|
86
133
|
def map_id_call?(node)
|
|
87
|
-
node.is_a?(Parser::AST::Node) &&
|
|
134
|
+
node.is_a?(Parser::AST::Node) && node.type == :send && %i[map collect].include?(node.children[1]) &&
|
|
135
|
+
node.children[2..].any? { |arg| symbol_to_proc_id?(arg) }
|
|
88
136
|
end
|
|
89
137
|
|
|
90
|
-
def
|
|
91
|
-
node.
|
|
92
|
-
%i[
|
|
138
|
+
def symbol_to_proc_id?(node)
|
|
139
|
+
node.is_a?(Parser::AST::Node) && node.type == :block_pass &&
|
|
140
|
+
node.children[0]&.type == :sym && %i[id to_i].include?(node.children[0].children[0])
|
|
93
141
|
end
|
|
94
142
|
|
|
95
|
-
def
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
end
|
|
143
|
+
def regular_pluck?(node) = node.children[2..].any? { |arg| pluck_var_in_hash?(arg) }
|
|
144
|
+
def critical_pluck?(node) = node.children[2..].any? { |arg| critical_pluck_in_hash?(arg) }
|
|
145
|
+
def small_collection?(node) = node.children[2..].any? { |arg| small_collection_in_hash?(arg) }
|
|
99
146
|
|
|
100
|
-
def
|
|
101
|
-
|
|
147
|
+
def pluck_var_in_hash?(node) = hash_with_value?(node) { |v| pluck_value?(v) }
|
|
148
|
+
def critical_pluck_in_hash?(node) = hash_with_value?(node) { |v| critical_value?(v) }
|
|
149
|
+
def small_collection_in_hash?(node) = hash_with_value?(node) { |v| small_collection_value?(v) }
|
|
102
150
|
|
|
103
|
-
|
|
104
|
-
|
|
151
|
+
def hash_with_value?(node, &block)
|
|
152
|
+
return false unless node.is_a?(Parser::AST::Node) && node.type == :hash
|
|
105
153
|
|
|
106
|
-
|
|
107
|
-
node.children[2..].any? { |arg| pluck_var_in_hash?(arg) }
|
|
154
|
+
node.children.any? { |pair| pair.type == :pair && block.call(pair.children[1]) }
|
|
108
155
|
end
|
|
109
156
|
|
|
110
|
-
def
|
|
111
|
-
|
|
157
|
+
def pluck_value?(val)
|
|
158
|
+
val.type == :lvar && (@pluck_variables.key?(val.children[0]) || @map_id_variables.key?(val.children[0]))
|
|
112
159
|
end
|
|
113
160
|
|
|
114
|
-
def
|
|
115
|
-
|
|
161
|
+
def critical_value?(val)
|
|
162
|
+
val.type == :lvar ? @critical_pluck_variables.key?(val.children[0]) : all_pluck_call?(val)
|
|
116
163
|
end
|
|
117
164
|
|
|
118
|
-
def
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
node.children.any? { |pair| pair.type == :pair && pluck_value?(pair.children[1]) }
|
|
165
|
+
def small_collection_value?(val)
|
|
166
|
+
val.type == :lvar && @small_collection_variables.key?(val.children[0])
|
|
122
167
|
end
|
|
123
168
|
|
|
124
|
-
def
|
|
125
|
-
|
|
169
|
+
def find_pluck_var_in_where(node)
|
|
170
|
+
node.children[2..].each do |arg|
|
|
171
|
+
next unless arg.is_a?(Parser::AST::Node) && arg.type == :hash
|
|
126
172
|
|
|
127
|
-
|
|
173
|
+
var = find_pluck_var_in_hash(arg)
|
|
174
|
+
return var if var
|
|
175
|
+
end
|
|
176
|
+
nil
|
|
128
177
|
end
|
|
129
178
|
|
|
130
|
-
def
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
node.children.any? { |pair| pair.type == :pair && small_collection_value?(pair.children[1]) }
|
|
134
|
-
end
|
|
179
|
+
def find_pluck_var_in_hash(hash_node)
|
|
180
|
+
hash_node.children.each do |pair|
|
|
181
|
+
next unless pair.type == :pair && pair.children[1].type == :lvar
|
|
135
182
|
|
|
136
|
-
|
|
137
|
-
|
|
183
|
+
var_name = pair.children[1].children[0]
|
|
184
|
+
return var_name if @pluck_variables.key?(var_name) || @map_id_variables.key?(var_name)
|
|
185
|
+
end
|
|
186
|
+
nil
|
|
138
187
|
end
|
|
139
188
|
|
|
140
|
-
def
|
|
141
|
-
|
|
142
|
-
end
|
|
189
|
+
def multi_use_variable?(var_name) = @variable_usages[var_name] > 1
|
|
190
|
+
def map_variable?(var_name) = @map_id_variables.key?(var_name)
|
|
143
191
|
|
|
144
|
-
def
|
|
145
|
-
|
|
192
|
+
def add_issue(node, var_name = nil)
|
|
193
|
+
message, suggestion = issue_content(var_name)
|
|
194
|
+
@issues << create_issue(file_path: @file_path, line_number: node.loc.line,
|
|
195
|
+
message: message, suggestion: suggestion, severity: :warning)
|
|
146
196
|
end
|
|
147
197
|
|
|
148
|
-
def
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
198
|
+
def issue_content(var_name)
|
|
199
|
+
if var_name && map_variable?(var_name)
|
|
200
|
+
["Using ID array from `.map(&:id)` in `where` causes two queries",
|
|
201
|
+
"If source is ActiveRecord, use `.select(:id)` subquery instead"]
|
|
202
|
+
else
|
|
203
|
+
["Using plucked array in `where` causes two queries and memory overhead",
|
|
204
|
+
"Use `.select(:id)` subquery instead: `Model.where(col: OtherModel.select(:id))`"]
|
|
205
|
+
end
|
|
156
206
|
end
|
|
157
207
|
|
|
158
208
|
def add_critical_issue(node)
|
|
159
|
-
@issues << create_issue(
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
suggestion: "Use `.select(:id)` subquery: `Model.where(col: OtherModel.select(:id))`",
|
|
164
|
-
severity: :error
|
|
165
|
-
)
|
|
209
|
+
@issues << create_issue(file_path: @file_path, line_number: node.loc.line,
|
|
210
|
+
message: "Using `.all.pluck(:id)` loads entire table into memory - highly inefficient",
|
|
211
|
+
suggestion: "Use `.select(:id)` subquery: `Model.where(col: OtherModel.select(:id))`",
|
|
212
|
+
severity: :error)
|
|
166
213
|
end
|
|
167
214
|
|
|
168
215
|
def add_info_issue(node)
|
|
169
216
|
@issues << create_issue(
|
|
170
|
-
file_path: @file_path,
|
|
171
|
-
line_number: node.loc.line,
|
|
217
|
+
file_path: @file_path, line_number: node.loc.line,
|
|
172
218
|
message: "Small collection pluck may be acceptable for few records",
|
|
173
219
|
suggestion: "Consider `.select(:id)` for consistency, but pluck is fine for small collections",
|
|
174
220
|
severity: :info
|
data/lib/eager_eye/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: eager_eye
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.2.
|
|
4
|
+
version: 1.2.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- hamzagedikkaya
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-01-
|
|
11
|
+
date: 2026-01-30 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: ast
|
|
@@ -66,6 +66,7 @@ files:
|
|
|
66
66
|
- lib/eager_eye/configuration.rb
|
|
67
67
|
- lib/eager_eye/detectors/base.rb
|
|
68
68
|
- lib/eager_eye/detectors/callback_query.rb
|
|
69
|
+
- lib/eager_eye/detectors/concerns/non_ar_source_detector.rb
|
|
69
70
|
- lib/eager_eye/detectors/count_in_iteration.rb
|
|
70
71
|
- lib/eager_eye/detectors/custom_method_query.rb
|
|
71
72
|
- lib/eager_eye/detectors/loop_association.rb
|
|
@@ -90,7 +91,6 @@ licenses:
|
|
|
90
91
|
- MIT
|
|
91
92
|
metadata:
|
|
92
93
|
allowed_push_host: https://rubygems.org
|
|
93
|
-
homepage_uri: https://github.com/hamzagedikkaya/eager_eye
|
|
94
94
|
source_code_uri: https://github.com/hamzagedikkaya/eager_eye
|
|
95
95
|
changelog_uri: https://github.com/hamzagedikkaya/eager_eye/blob/master/CHANGELOG.md
|
|
96
96
|
rubygems_mfa_required: 'true'
|