eager_eye 0.3.0 → 0.5.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 037dbef0e4dc82641ae8c56bb8110f822900dd7afe76ddac6051b375bb547208
4
- data.tar.gz: 1d0878ea0046406e264ddfa92d68ac0c9f1748aea4803d9a638554bb13ea77c9
3
+ metadata.gz: 031e0f3547538856718932cd6121ac990ad8c11a43682ec076f68bb591943d55
4
+ data.tar.gz: 433563980a743704c361304dff56866fe4cc9795a1d6f927cd0b4a8fc0367c8f
5
5
  SHA512:
6
- metadata.gz: b97082ff4f9176c773a1007e9045305ad4c1d9ae46898bc1969eb66e6dd9bb42605bf567875ba7ba429693b1cd61385274587c51d6d0dc0397fc15f304d49af7
7
- data.tar.gz: ca8e47200e8d140361dfb57f37fff56616d1c75c284b19de43afeb2c1f179cdf506731866ccaf4b531e867877fd2815de2e6082134b8f6754bfe861068647ff4
6
+ metadata.gz: 5c9f874001fb0c71eb8c1858ef2739181482992b7455925afc696acfa1b7bebfa91888d305936ab6189f64ea1542afd7844b95c4ba453218d0c706d40d17304b
7
+ data.tar.gz: 93750c566392f234cb5009d30873130182a4e80722b9f29402b662caacc0c961a31a8c109937c378f9a04b3d0f6e134326a0edeb0d138255debc2c9fb4ceb9a3
data/CHANGELOG.md CHANGED
@@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.0] - 2025-12-15
11
+
12
+ ### Added
13
+
14
+ - **New Detector: `PluckToArray`** - Detects pluck/map results used in where clauses
15
+ - Catches `.pluck(:id)` and `.ids` results used in `where` clauses
16
+ - Catches `.map(&:id)` and `.collect(&:id)` patterns
17
+ - Suggests using `.select(:id)` subquery pattern for better performance
18
+ - Prevents two queries and memory overhead from holding IDs in arrays
19
+
20
+ ### Changed
21
+
22
+ - Updated default `enabled_detectors` to include `:pluck_to_array`
23
+ - Updated README with new detector documentation and performance comparison
24
+
25
+ ## [0.4.0] - 2025-12-15
26
+
27
+ ### Added
28
+
29
+ - **New Detector: `CallbackQuery`** - Detects database queries and iterations inside ActiveRecord callbacks
30
+ - Identifies potential bulk operation performance issues
31
+ - Detects query methods (`.count`, `.sum`, `.update!`, etc.) in callbacks
32
+ - Detects iterations (`.each`, `.map`, etc.) in callbacks as errors
33
+ - Severity: `:error` for iterations in callbacks, `:warning` for query methods
34
+ - Suggests moving to background jobs or using conditional callbacks
35
+
36
+ ### Changed
37
+
38
+ - Updated default `enabled_detectors` to include `:callback_query`
39
+ - Updated README with new detector documentation
40
+
10
41
  ## [0.3.0] - 2025-12-15
11
42
 
12
43
  ### Added
data/README.md CHANGED
@@ -205,6 +205,73 @@ user.posts_count # Just reads the column
205
205
  | `.size` | Array#size | COUNT query |
206
206
  | `.length` | Array#length | Loads all, then counts |
207
207
 
208
+ ### 6. Callback Query Detection
209
+
210
+ Detects database queries and iterations inside ActiveRecord callbacks. These become performance disasters during bulk operations.
211
+
212
+ ```ruby
213
+ # Bad - Queries in callbacks
214
+ class Article < ApplicationRecord
215
+ after_save :recalculate_stats
216
+
217
+ def recalculate_stats
218
+ author.articles.published.count # Runs on EVERY save
219
+ category.update_article_count! # Another query on EVERY save
220
+ end
221
+ end
222
+
223
+ # Disaster scenario
224
+ Article.import(1000.times.map { |i| { title: "Post #{i}" } })
225
+ # = 2000+ queries from callbacks!
226
+
227
+ # Bad - N+1 in callback
228
+ class Order < ApplicationRecord
229
+ after_create :notify_subscribers
230
+
231
+ def notify_subscribers
232
+ customer.followers.each do |follower| # N+1!
233
+ NotificationMailer.new_order(follower).deliver_later
234
+ end
235
+ end
236
+ end
237
+
238
+ # Good - Use conditional callbacks
239
+ after_save :recalculate_stats, if: :should_recalculate?
240
+
241
+ # Good - Move to background job
242
+ after_commit :schedule_stats_update, on: :create
243
+
244
+ def schedule_stats_update
245
+ RecalculateStatsJob.perform_later(id)
246
+ end
247
+ ```
248
+
249
+ ### 7. Pluck to Array Misuse
250
+
251
+ Detects when `.pluck(:id)` or `.map(&:id)` results are used in `where` clauses instead of subqueries.
252
+
253
+ ```ruby
254
+ # Bad - Two queries + memory overhead
255
+ user_ids = User.active.pluck(:id) # Query 1: SELECT id FROM users
256
+ Post.where(user_id: user_ids) # Query 2: SELECT * FROM posts WHERE user_id IN (1,2,3...)
257
+ # Also holds potentially thousands of IDs in memory
258
+
259
+ # Bad - Same problem with map
260
+ user_ids = users.map(&:id)
261
+ Post.where(user_id: user_ids)
262
+
263
+ # Good - Single subquery, no memory overhead
264
+ Post.where(user_id: User.active.select(:id))
265
+ # Single query: SELECT * FROM posts WHERE user_id IN (SELECT id FROM users WHERE active = true)
266
+ ```
267
+
268
+ **Performance comparison with 10,000 users:**
269
+
270
+ | Approach | Queries | Memory | Time |
271
+ |----------|---------|--------|------|
272
+ | `pluck` + `where` | 2 | ~80KB for IDs | ~45ms |
273
+ | `select` subquery | 1 | None | ~20ms |
274
+
208
275
  ## Configuration
209
276
 
210
277
  ### Config File (.eager_eye.yml)
@@ -222,6 +289,8 @@ enabled_detectors:
222
289
  - missing_counter_cache
223
290
  - custom_method_query
224
291
  - count_in_iteration
292
+ - callback_query
293
+ - pluck_to_array
225
294
 
226
295
  # Base path to analyze (default: app)
227
296
  app_path: app
@@ -9,7 +9,9 @@ module EagerEye
9
9
  serializer_nesting: Detectors::SerializerNesting,
10
10
  missing_counter_cache: Detectors::MissingCounterCache,
11
11
  custom_method_query: Detectors::CustomMethodQuery,
12
- count_in_iteration: Detectors::CountInIteration
12
+ count_in_iteration: Detectors::CountInIteration,
13
+ callback_query: Detectors::CallbackQuery,
14
+ pluck_to_array: Detectors::PluckToArray
13
15
  }.freeze
14
16
 
15
17
  attr_reader :paths, :issues
@@ -6,7 +6,8 @@ module EagerEye
6
6
 
7
7
  DEFAULT_DETECTORS = %i[
8
8
  loop_association serializer_nesting missing_counter_cache
9
- custom_method_query count_in_iteration
9
+ custom_method_query count_in_iteration callback_query
10
+ pluck_to_array
10
11
  ].freeze
11
12
 
12
13
  def initialize
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EagerEye
4
+ module Detectors
5
+ class CallbackQuery < Base
6
+ CALLBACK_METHODS = %i[
7
+ before_validation after_validation
8
+ before_save after_save around_save
9
+ before_create after_create around_create
10
+ before_update after_update around_update
11
+ before_destroy after_destroy around_destroy
12
+ after_commit after_rollback
13
+ after_create_commit after_update_commit after_destroy_commit
14
+ after_save_commit
15
+ ].freeze
16
+
17
+ QUERY_INDICATORS = %i[
18
+ where find find_by find_by! first last take
19
+ exists? any? none? many? one?
20
+ count sum average minimum maximum
21
+ pluck ids select
22
+ update update_all update! update_attribute update_column update_columns
23
+ destroy destroy_all destroy! delete delete_all
24
+ create create! save save! insert insert_all insert! upsert upsert_all
25
+ increment! decrement! toggle!
26
+ reload
27
+ ].freeze
28
+
29
+ ITERATION_METHODS = %i[each map select find_all reject collect].freeze
30
+
31
+ def self.detector_name
32
+ :callback_query
33
+ end
34
+
35
+ def detect(ast, file_path)
36
+ @issues = []
37
+ @file_path = file_path
38
+ @callback_methods = {}
39
+
40
+ return @issues unless ast
41
+
42
+ find_callback_definitions(ast)
43
+ check_callback_methods(ast)
44
+
45
+ @issues
46
+ end
47
+
48
+ private
49
+
50
+ def find_callback_definitions(node)
51
+ return unless node.is_a?(Parser::AST::Node)
52
+
53
+ extract_callback_method_name(node) if callback_definition?(node)
54
+
55
+ node.children.each do |child|
56
+ find_callback_definitions(child)
57
+ end
58
+ end
59
+
60
+ def callback_definition?(node)
61
+ return false unless node.type == :send
62
+ return false unless node.children[0].nil?
63
+
64
+ method_name = node.children[1]
65
+ CALLBACK_METHODS.include?(method_name)
66
+ end
67
+
68
+ def extract_callback_method_name(node)
69
+ node.children[2..].each do |arg|
70
+ next unless arg.is_a?(Parser::AST::Node) && arg.type == :sym
71
+
72
+ method_name = arg.children[0]
73
+ callback_type = node.children[1]
74
+ @callback_methods[method_name] = callback_type
75
+ end
76
+ end
77
+
78
+ def check_callback_methods(node)
79
+ return unless node.is_a?(Parser::AST::Node)
80
+
81
+ if method_definition?(node)
82
+ method_name = node.children[0]
83
+ if @callback_methods.key?(method_name)
84
+ callback_type = @callback_methods[method_name]
85
+ check_method_body_for_queries(node, method_name, callback_type)
86
+ end
87
+ end
88
+
89
+ node.children.each do |child|
90
+ check_callback_methods(child)
91
+ end
92
+ end
93
+
94
+ def method_definition?(node)
95
+ node.type == :def
96
+ end
97
+
98
+ def check_method_body_for_queries(method_node, method_name, callback_type)
99
+ method_body = method_node.children[2]
100
+ return unless method_body
101
+
102
+ find_query_calls(method_body, method_name, callback_type)
103
+ find_iteration_with_queries(method_body, method_name, callback_type)
104
+ end
105
+
106
+ def find_query_calls(node, method_name, callback_type)
107
+ return unless node.is_a?(Parser::AST::Node)
108
+
109
+ add_query_issue(node, method_name, callback_type) if query_call?(node)
110
+
111
+ node.children.each do |child|
112
+ find_query_calls(child, method_name, callback_type)
113
+ end
114
+ end
115
+
116
+ def find_iteration_with_queries(node, method_name, callback_type)
117
+ return unless node.is_a?(Parser::AST::Node)
118
+
119
+ add_iteration_issue(node, method_name, callback_type) if iteration_block?(node)
120
+
121
+ node.children.each do |child|
122
+ find_iteration_with_queries(child, method_name, callback_type)
123
+ end
124
+ end
125
+
126
+ def query_call?(node)
127
+ return false unless node.type == :send
128
+
129
+ method = node.children[1]
130
+ QUERY_INDICATORS.include?(method)
131
+ end
132
+
133
+ def iteration_block?(node)
134
+ return false unless node.type == :block
135
+
136
+ send_node = node.children[0]
137
+ return false unless send_node&.type == :send
138
+
139
+ method_name = send_node.children[1]
140
+ ITERATION_METHODS.include?(method_name)
141
+ end
142
+
143
+ def add_query_issue(node, method_name, callback_type)
144
+ query_method = node.children[1]
145
+
146
+ @issues << create_issue(
147
+ file_path: @file_path,
148
+ line_number: node.loc.line,
149
+ message: "Query method `.#{query_method}` found in `#{callback_type}` callback `:#{method_name}`",
150
+ severity: :warning,
151
+ suggestion: "Callbacks run on every save/create/update. Consider moving to a background job"
152
+ )
153
+ end
154
+
155
+ def add_iteration_issue(node, method_name, callback_type)
156
+ @issues << create_issue(
157
+ file_path: @file_path,
158
+ line_number: node.loc.line,
159
+ message: "Iteration found in `#{callback_type}` callback `:#{method_name}` - potential N+1",
160
+ severity: :error,
161
+ suggestion: "Avoid iterations in callbacks. Use background jobs for bulk operations"
162
+ )
163
+ end
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EagerEye
4
+ module Detectors
5
+ class PluckToArray < Base
6
+ def self.detector_name
7
+ :pluck_to_array
8
+ end
9
+
10
+ def detect(ast, file_path)
11
+ @issues = []
12
+ @file_path = file_path
13
+ @pluck_variables = {}
14
+ @map_id_variables = {}
15
+
16
+ return @issues unless ast
17
+
18
+ collect_pluck_assignments(ast)
19
+ collect_map_id_assignments(ast)
20
+ find_where_with_pluck_var(ast)
21
+
22
+ @issues
23
+ end
24
+
25
+ private
26
+
27
+ def collect_pluck_assignments(node)
28
+ return unless node.is_a?(Parser::AST::Node)
29
+
30
+ if local_variable_assignment?(node)
31
+ var_name = node.children[0]
32
+ value = node.children[1]
33
+
34
+ @pluck_variables[var_name] = node.loc.line if pluck_call?(value)
35
+ end
36
+
37
+ node.children.each do |child|
38
+ collect_pluck_assignments(child)
39
+ end
40
+ end
41
+
42
+ def collect_map_id_assignments(node)
43
+ return unless node.is_a?(Parser::AST::Node)
44
+
45
+ if local_variable_assignment?(node)
46
+ var_name = node.children[0]
47
+ value = node.children[1]
48
+
49
+ @map_id_variables[var_name] = node.loc.line if map_id_call?(value)
50
+ end
51
+
52
+ node.children.each do |child|
53
+ collect_map_id_assignments(child)
54
+ end
55
+ end
56
+
57
+ def find_where_with_pluck_var(node)
58
+ return unless node.is_a?(Parser::AST::Node)
59
+
60
+ add_issue(node) if where_call_with_pluck_var?(node)
61
+
62
+ node.children.each do |child|
63
+ find_where_with_pluck_var(child)
64
+ end
65
+ end
66
+
67
+ def local_variable_assignment?(node)
68
+ node.type == :lvasgn
69
+ end
70
+
71
+ def pluck_call?(node)
72
+ return false unless node.is_a?(Parser::AST::Node)
73
+ return false unless node.type == :send
74
+
75
+ method_name = node.children[1]
76
+ %i[pluck ids].include?(method_name)
77
+ end
78
+
79
+ def map_id_call?(node)
80
+ return false unless node.is_a?(Parser::AST::Node)
81
+
82
+ case node.type
83
+ when :block then block_map_call?(node)
84
+ when :send then send_map_id_call?(node)
85
+ else false
86
+ end
87
+ end
88
+
89
+ def block_map_call?(node)
90
+ send_node = node.children[0]
91
+ return false unless send_node&.type == :send
92
+
93
+ %i[map collect].include?(send_node.children[1])
94
+ end
95
+
96
+ def send_map_id_call?(node)
97
+ method_name = node.children[1]
98
+ return false unless %i[map collect].include?(method_name)
99
+
100
+ node.children[2..].any? { |arg| symbol_to_proc_id?(arg) }
101
+ end
102
+
103
+ def symbol_to_proc_id?(node)
104
+ return false unless node.is_a?(Parser::AST::Node)
105
+ return false unless node.type == :block_pass
106
+
107
+ sym_node = node.children[0]
108
+ return false unless sym_node&.type == :sym
109
+
110
+ %i[id to_i].include?(sym_node.children[0])
111
+ end
112
+
113
+ def where_call_with_pluck_var?(node)
114
+ return false unless node.type == :send
115
+ return false unless node.children[1] == :where
116
+
117
+ args = node.children[2..]
118
+ args.any? { |arg| hash_with_pluck_var?(arg) }
119
+ end
120
+
121
+ def hash_with_pluck_var?(node)
122
+ return false unless node.is_a?(Parser::AST::Node)
123
+ return false unless node.type == :hash
124
+
125
+ node.children.any? do |pair|
126
+ next false unless pair.type == :pair
127
+
128
+ value = pair.children[1]
129
+ if value.type == :lvar
130
+ var_name = value.children[0]
131
+ @pluck_variables.key?(var_name) || @map_id_variables.key?(var_name)
132
+ else
133
+ false
134
+ end
135
+ end
136
+ end
137
+
138
+ def add_issue(node)
139
+ @issues << create_issue(
140
+ file_path: @file_path,
141
+ line_number: node.loc.line,
142
+ message: "Using plucked/mapped array in `where` causes two queries and holds IDs in memory",
143
+ severity: :warning,
144
+ suggestion: "Use `.select(:id)` subquery: `Model.where(col: OtherModel.condition.select(:id))`"
145
+ )
146
+ end
147
+ end
148
+ end
149
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module EagerEye
4
- VERSION = "0.3.0"
4
+ VERSION = "0.5.0"
5
5
  end
data/lib/eager_eye.rb CHANGED
@@ -9,6 +9,8 @@ require_relative "eager_eye/detectors/serializer_nesting"
9
9
  require_relative "eager_eye/detectors/missing_counter_cache"
10
10
  require_relative "eager_eye/detectors/custom_method_query"
11
11
  require_relative "eager_eye/detectors/count_in_iteration"
12
+ require_relative "eager_eye/detectors/callback_query"
13
+ require_relative "eager_eye/detectors/pluck_to_array"
12
14
  require_relative "eager_eye/analyzer"
13
15
  require_relative "eager_eye/reporters/base"
14
16
  require_relative "eager_eye/reporters/console"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: eager_eye
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - hamzagedikkaya
@@ -61,10 +61,12 @@ files:
61
61
  - lib/eager_eye/cli.rb
62
62
  - lib/eager_eye/configuration.rb
63
63
  - lib/eager_eye/detectors/base.rb
64
+ - lib/eager_eye/detectors/callback_query.rb
64
65
  - lib/eager_eye/detectors/count_in_iteration.rb
65
66
  - lib/eager_eye/detectors/custom_method_query.rb
66
67
  - lib/eager_eye/detectors/loop_association.rb
67
68
  - lib/eager_eye/detectors/missing_counter_cache.rb
69
+ - lib/eager_eye/detectors/pluck_to_array.rb
68
70
  - lib/eager_eye/detectors/serializer_nesting.rb
69
71
  - lib/eager_eye/generators/install_generator.rb
70
72
  - lib/eager_eye/issue.rb